はじめに
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
で待ち受けるようにする