この記事は前回の記事の続き、および補足です。
前回記事 (C#) 同期コンテキストと Task, async/await - ネコのために鐘は鳴る
UI スレッドへの復帰コスト
前回の記事で上げた GUI ループでのキューの監視は、ループごとにキューにコールバックがPost
されていないか確認しています。
しかし、GUIのループは基本的に描画速度に依存し、60FPSを前提とする環境では1ループ最短で 16ms かかります。つまり、キューを確認した直後に非同期からコールバックをPost
された場合、UI スレッドがきちんと回っている場合でも、コールバックを拾うまでに最長 16ms 遅れます。
これは、わざわざコールバックで UI スレッド復帰する必要がない場合、不要なコストです。
UI スレッド復帰しないawait
Task
のawait
のコールバックを UI スレッドに戻す必要がない場合、Task.ConfigureAwait
メソッドを使うことで、UI スレッドに復帰せずにそのままコールバックを実行できます。
// ここは UI スレッド Debug.WriteLine("Start"); var task = await Task.Run(() => { // ここはワーカースレッド Debug.WriteLine("Worker Thread"); }).ConfigureAwait(false); // スレッド復帰しない // ここは同じワーカースレッド Debug.WriteLine("Callback");
Task
のデフォルトではConfigureAwait
はtrue
に設定されており、この場合、同期コンテキストによる通知はSynchronizationContext.Post
メソッドによって行われます。(前回記事参照)
Task.ConfigureAwait(false)
にした場合、通知はSynchronizationContext.Send
によって行われ、同期的に実行されます。混乱しそうですが、ワーカースレッドから見て同期的ということは、コールバックは同じワーカースレッドで実行されるということです。
つまり、Task
中の処理の続きで連続して行われるため、UI スレッド復帰によるコストは発生しません。
ありがちな罠
// ここは UI スレッド var task1 = await Task.Run(() => { // ここはワーカースレッド1 }).ConfigureAwait(false); // ここはワーカースレッド1 var task2 = await Task.Run(() => { // ここはワーカースレッド2 }).ConfigureAwait(true); // ***注意*** // ここはワーカースレッド2
上記のコードの場合、一番下の部分はConfigureAwait(true)
の後なので UI スレッドのように見えますが、UI スレッドではありません。
ならば、task2
を呼び出したのはワーカースレッド1なのだから、ここはスレッド復帰してワーカースレッド1なのかというと、それも違います。正解はワーカースレッド2です。
そもそも 非 UI スレッドの同期コンテキストはスレッド復帰を行わない (前回記事参照) ので最後の部分はワーカースレッド2で実行されます。
最後の部分で UI スレッドに復帰したい場合はTask.ContinueWith
メソッドを使います。
// ここは UI スレッド var task = await Task.Run(() => { // ここはワーカースレッド1 }).ContinueWith(() => { // ここはワーカースレッド1 }).ConfigureAwait(true); // ここは UI スレッド
説明のためConfigureAwait(true)
をつけていますが、もともとtrue
なのでなくても動作は同じです。
ライブラリ作成時の注意点
Task
を内部で使って非同期処理を行うようなライブラリを作る場合、await
を使う場合原則としてConfigureAwait(false)
を必ず付ける必要があります。
つけ忘れると、意図しない部分でスレッドが UI スレッドに復帰してしまう上、コンパイルされたライブラリ dll の利用者から見ると、そのライブラリが中でawait
をつかったスレッド復帰をしているかどうかを確認する術がありません。
なので、基本的にライブラリとして他者に使ってもらう前提のコード中のTask
にはConfigureAwait(false)
を必ず付ける必要があります。