はじめに
Unity 2018から.NET 4.6がstableになり、async/awaitやTask使いこなせるようになっておく必要がでてきました。
今回はそれらの裏に隠れている、SynchronizationContextという機構とその使い方について紹介します。
なお、この記事はUnity2018.1bを基準に書いています。
SynchronizationContextとは何なのか
SynchronizationContextとは、ざっくり説明すると「指定した処理を指定したスレッドで実行するために用いるオブジェクト」です。
非同期処理を実行する場合、あるスレッドから別スレッドに処理を移譲することになります。
このとき、「非同期処理が終わったときに元のスレッドに処理を戻す」という需要が発生します。
これを解決するのがSynchronizationContextというオブジェクトです。
UniRxを普段利用されている方はSchedulerをイメージしていいただければいいかと思います。
使い方
取得方法
SynchronizationContextを利用するとなった場合、まずこのオブジェクトを取得する必要があります。
**Unityメインスレッド(重要)**でSynchronizationContextを利用する場合は、SynchronizationContext.Currentにアクセスすることで有効なSynchronizationContextを取得することができます。
void Start()
{
var context = SynchronizationContext.Current;
Debug.Log(context);
}
UnityEngine.UnitySynchronizationContext
SynchronizationContextを使って別スレッドからUnityメインスレッドに処理を渡す
SynchronizationContext.Postメソッドを実行することで、該当の処理をUnityメインスレッドにて実行することができます。
SynchronizationContextを用いた場合と用いない場合とで挙動の違いを示します。
比較 : SynchronizationContextを用いず、別スレッドで処理を継続させる
次の処理はSynchronizationContextを利用せず、処理をスレッドプール上にてそのまま継続した場合のコードです。
void Start()
{
Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);
// スレッドプール上にて処理を実行する
ThreadPool.QueueUserWorkItem(_ =>
{
Debug.Log("スレッドプールのスレッドID:" + Thread.CurrentThread.ManagedThreadId);
// なにか処理
Thread.Sleep(100);
EndAction();
});
}
void EndAction()
{
Debug.Log("処理が終わった!");
Debug.Log("実行後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
メインスレッドID:1
スレッドプールのスレッドID:19
処理が終わった!
実行後のスレッドID:19
EndAction()がそのままスレッドプール上で処理が行われています。
比較 : SynchronizationContextを用いて、非同期処理をメインスレッド戻す
次の処理はSynchronizationContextを利用し、処理をメインスレッドに戻した場合のコードです。
void Start()
{
// メインスレッドのSynchronizationContextを先に確保しておく
var context = SynchronizationContext.Current;
Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);
// スレッドプール上にて処理を実行する
ThreadPool.QueueUserWorkItem(_ =>
{
Debug.Log("スレッドプールのスレッドID:" + Thread.CurrentThread.ManagedThreadId);
// なにか処理
Thread.Sleep(100);
// 確保したSynchronizationContextを使ってメインスレッドに処理を戻す
context.Post(__ =>
{
EndAction();
}, null);
});
}
void EndAction()
{
Debug.Log("処理が終わった!");
Debug.Log("実行後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
メインスレッドID:1
スレッドプールのスレッドID:21
処理が終わった!
実行後のスレッドID:1
SynchronizationContext.Postを用いた場合、EndAction()がメインスレッドに戻ってきて実行されていることがわかります。
このように、SynchronizationContextを用いることで簡単に元のスレッドに処理を戻すことができるようになります。
SynchronizationContextはいつでも取得できるのか?
この便利なSynchronizationContextですが、(Unityで動いているコードであれば)Unityメインスレッド上でしか取得することができません。
void Start()
{
Debug.Log("メインスレッド:" + (SynchronizationContext.Current != null));
ThreadPool.QueueUserWorkItem(_ =>
{
Debug.Log("スレッドプール:" + (SynchronizationContext.Current != null));
});
new Thread(() =>
{
Debug.Log("新しく作ったスレッド:" + (SynchronizationContext.Current != null));
}).Start();
}
メインスレッド:True
新しく作ったスレッド:False
スレッドプール:False
なぜメインスレッド以外ではnullになってしまうかというと、SynchronizationContext.Currentはユーザが手動で設定する必要がある項目だからです。
UnityメインスレッドでSynchronizationContext.Currentが使えるのは、UnityEngineが気を利かせてSynchronizationContextを設定してくれているからに過ぎません。
つまり?
SynchronizationContext.Currentから有効なインスタンスを取得できるのはUnityメインスレッド上のみ- それ以外は手動で設定する必要がある(が、手動で設定が必要になるようなことはUnity開発ではほぼ無い)
「SynchronizationContext」と「Taskのawait」
UnityメインスレッドでTaskのawaitをした場合
この便利なSynchronizationContextですが、実はTaskをawaitした際にも暗黙的に利用されています。
Taskをawaitしたときに、デフォルトでは呼び出し元スレッドのSynchronizationContext.Currentに基づいてawait以降の処理を継続するという仕組みになっています。
そのため、Unityメインスレッドでawaitを実行した場合、await後の処理もそのままUnityメインスレッドで継続して実行されるようになっています。
void Start()
{
Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);
Work();
}
async Task Work()
{
Debug.Log("await前のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
// スレッドプール上で処理を実行して終わるのを待つ
await Task.Run(() =>
{
Debug.Log("スレッドプールのID:" + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(100);
});
Debug.Log("await後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:24
await後のスレッドID:1
SynchronizationContext.Currentを無視したい場合
もしawait後にUnityメインスレッドに戻っては困るような場合(そのままスレッドプール上で処理を続けたい場合)は、Task.ConfigureAwait(false)を指定します。
ConfigureAwaitは「await後にSynchronizationContextを用いるかどうか」の設定で、falseに設定することでTaskの実行スレッドをそのまま継続するようになります。(デフォルトはtrueになっています。)
void Start()
{
Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);
Work();
}
async Task Work()
{
Debug.Log("await前のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
// スレッドプール上で処理を実行して終わるのを待つ
await Task.Run(() =>
{
Debug.Log("スレッドプールのID:" + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(100);
}).ConfigureAwait(false); // ConfigureAwait(false)のためメインスレッド戻らない
Debug.Log("await後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:21
await後のスレッドID:21
やっぱり最後はメインスレッドに戻したい場合
Task.ConfigureAwait(false)を指定したはいいものの、やはり最後はメインスレッドに戻したいとなった場合は手動でSynchronizationContext.Postを呼び出す必要があります。
ただしその場合、await後に``SynchronizationContext.Currentにアクセスしてもnullが返ってきてしまうため、await`実行前(メインスレッド上で処理している間)に`SynchronizationContext`を確保しておく必要があります。
void Start()
{
Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);
Work();
}
async Task Work()
{
// メインスレッドで実行している間にSynchronizationContextを確保しておく
var context = SynchronizationContext.Current;
Debug.Log("await前のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
await Task.Run(() =>
{
Debug.Log("スレッドプールのID:" + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(100);
}).ConfigureAwait(false); // ConfigureAwait(false)のためメインスレッド戻らない
Debug.Log("await後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
//メインスレッドに処理を戻す
context.Post(_ =>
{
Debug.Log("SynchronizationContextで戻ってきたスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}, null);
}
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:22
await後のスレッドID:22
SynchronizationContextで戻ってきたスレッドID:1
TaskのWait()やResultの安易な呼び出しはデッドロックを引き起こす
Task.Wait()
TaskにはWait()という、「非同期処理の完了をスレッドをブロックして同期的に待つ」という大変危険なメソッドが用意されています。
というのも、このWait()はスレッドをブロックするだけでも結構ヤバイのですが、SynchronizationContext.Currentが設定されている状態でWait()すると簡単にデッドロックを起こしてしまいます。
void Start()
{
// ここでTaskの実行開始
var task = Work();
// デッドロック!(Unityがフリーズする)
task.Wait();
}
async Task Work()
{
Debug.Log("実行開始");
await Task.Delay(1000);
Debug.Log("実行終了");
}
Task.Result
また、Taskの結果を取得するResultというプロパティもWait()と同様にスレッドをブロックするため、安易に用いるとデッドロックを起こします。
そのためTaskの返り値を取得する場合はawaitを用いるようにしましょう。
void Start()
{
var task = ReadTextAsync("data.txt");
// やってることはWait()と同じでデッドロックを起こす
var result = task.Result;
Debug.Log(result);
}
/// <summary>
/// 指定したファイルを非同期で読み込む
/// </summary>
private async Task<string> ReadTextAsync(string filePath)
{
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
using (var reader = new StreamReader(stream))
{
return await reader.ReadToEndAsync();
}
}
デッドロックを起こす理由
デッドロックを起こす理由は、「SynchronazationContext.Postを使ってメインスレッドへ処理を戻そうにも、Wait()によってメインスレッドがブロックされているため、何もできなくなる」からです。
つまり、「自分が実行しないと終了しない処理が終了するのを自分が待ち続ける」みたいな状態になってしまってデッドロックになってしまいます。
デッドロックの回避策
回避策1.ちゃんとawaitする
async void Start()
{
var task = ReadTextAsync("data.txt");
// awaitで結果を待つ
var result = await task;
Debug.Log(result);
}
/// <summary>
/// 指定したファイルを非同期で読み込む
/// </summary>
private async Task<string> ReadTextAsync(string filePath)
{
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
using (var reader = new StreamReader(stream))
{
return await reader.ReadToEndAsync();
}
}
回避策2. ConfigureAwait(false)にして元のスレッドに戻ってこないようにする
void Start()
{
var task = ReadTextAsync("data.txt");
var result = task.Result;
Debug.Log(result);
}
/// <summary>
/// 指定したファイルを非同期で読み込む
/// </summary>
private async Task<string> ReadTextAsync(string filePath)
{
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
using (var reader = new StreamReader(stream))
{
// ConfigureAwait(false)でメインスレッド戻らないようにする
return await reader.ReadToEndAsync().ConfigureAwait(false);
}
}
まとめ
-
SynchronizationContextを使うと処理の実行スレッドを切り替えることができる - Unityメインスレッドにはデフォルトで有効な
SynchronizationContextが設定済み(SynchronizationContext.Currentで取得可能) - Unityメインスレッドで
awaitを実行した場合はちゃんとメインスレッド戻ってくる -
Task.Wait()やTask.Resultはデッドロックは簡単に引き起こすので、できるだけawaitで待ち受けるようにする
