.NET framework 4 で追加されたTask
および .NET framework 4.5 で追加されたasync
, await
キーワードによって、C# の非同期処理は非常にに簡単になりました。
Windows Form, WPF, UWP, Unity など各種フレームワーク上の場合、非同期処理はスレッドやコールバック処理など面倒なことはほぼ完璧に隠蔽され、難しいことを意識することなく直感的に記述できるようになりました。
しかし、例えば上記のような既存のフレームワークを使わず、独自の GUI フレームワークを作りたいような場合、同期コンテキストについて知っておく必要があります。
(GUIフレームワークを自分で作る人がいるのかという疑問はひとまず置いておいて……)
なお、ここでいうフレームワークとは、開発者がその上でアプリケーションを作成することを目的としているような、基盤となるようなライブラリ群のことを指しています。
GUI とスレッド
C# に限らず、GUI のアプリケーションは基本的に UI の操作はシングルスレッドから行うことを前提とし、UI スレッドは特別視されます。理由は、スレッドセーフにGUI フレームワークを作ることは実装面のコストが大きく、実行速度面から見ても不利であるためです。それなら初めから GUI はシングルスレッド前提の設計をしてしまおう、というのが定石です。
しかし、UI スレッドで時間のかかる処理を実行することは、UI のフリーズを意味します。そこで、重い処理は非同期に実行し、実行結果を UI スレッドにコールバックするのが普通です。
void SyncMethod()
{
// 同期的に計算処理を行う
int result = HeavyCalculation();
Debug.WriteLine(result);
}
async Task AsyncMethod()
{
// 非同期に計算処理を行う
// ここは UI スレッド
// 別スレッドに重い処理を実行させる
int result = await Task.Factory.StartNew(HeavyCalculation);
// ここは UI スレッドに戻ってきている
Debug.WriteLine(result);
}
int HeavyCalculation()
{
int result = 0;
// ここで重い計算処理をおこなう
return result;
}
上のSyncMethod()
とAsyncMethod()
はともに同じresult
を得ますが、SyncMethod()
は同期的に実行されるため、例えば計算処理に10秒かかる場合、10秒間 UI がフリーズします。
一方、AsyncMethod()
は計算処理部分を別スレッドに投げ、結果が出た時に再び UI スレッドに処理が戻ってくるため、計算中に UI がフリーズしません。
実際に別スレッドで実行され、実行後にUIスレッドに戻ってきているのか確認してみましょう
static async Task CheckThreadID()
{
// ここは UI スレッド
var uiThread = Thread.CurrentThread.ManagedThreadId;
Debug.WriteLine($"UI Thread : {uiThread}"); // 1
// 別スレッドに重い処理を実行させる
await Task.Factory.StartNew(AnotherThread);
// ここは UI スレッドに戻ってきている
var callbackThread = Thread.CurrentThread.ManagedThreadId;
Debug.WriteLine($"Callback Thread : {callbackThread}"); // 1
}
static void AnotherThread()
{
var anotherThread = Thread.CurrentThread.ManagedThreadId;
Debug.WriteLine($"Another Thread : {anotherThread}"); // 2
}
このCheckThreadID()
実行すると、
UI Thread : 1
Another Thread : 2
Callback Thread : 1
のように出力されます。(スレッド ID の数字は実行環境によって変わります)
非同期処理を呼び出した後、呼び出し元のスレッドに処理が戻ってきていることが確認できます。
コンソールアプリケーションの場合
では、GUI フレームワークを使わない環境、つまりコンソールアプリケーションの場合どうなるのでしょうか。
なお、コンソールアプリケーションでTask
を使う場合、非同期処理が終了する前にMain
メソッドを抜けてしまうとアプリケーションが終了してしまうため、以下のように書きます。
class Program
{
static void Main(string[] args) => MainAsync(args).Wait();
static async Task MainAsync(string[] args)
{
// ここに普通にMain関数の中身を書く
await CheckThreadID(); // スレッドIDを確認する
}
}
ここに先ほどの非同期スレッドのスレッド ID を確認するプログラムを書いてみた場合、結果は以下のようになります。
UI Thread : 1
Another Thread : 2
Callback Thread : 2
(スレッド ID の数字は実行環境によって変わります)
UI スレッド (この場合Main
関数が走っているメインスレッド) の ID が 1
なのに対し、非同期処理から戻ってきたときのスレッドは、元のスレッドとは別のスレッドになっています。
これはどうしてなのでしょうか。
同期コンテキスト (SynchronizationContext
)
System.Threading
名前空間にSynchronizationContext
というクラスがあります。これが同期コンテキストです。
実はTask
が非同期処理からどのスレッドに帰ってくるかはこの同期コンテキストが関係しています。
System.ComponentModel
名前空間にAsyncOperationManager
というstatic
クラスがあり、これがSynchronizationContext
というstatic
プロパティを持っています。
このプロパティは、このプロパティを呼び出したスレッドに紐づいている同期コンテキストを取得することができます。
この同期コンテキストというのは各スレッドごとに1つあり、null
またはSynchronizationContext
クラスのインスタンスです。
(以降、スレッドに紐づいている同期コンテキストがnull
またはSynchronizationContext
インスタンスの状態のことを、デフォルトの同期コンテキストと呼ぶことにします)
デフォルトの同期コンテキストは、実質何も処理をしないため、Task
による非同期処理のコールバックは元のスレッドに戻ってくることはありません。
では Windows Form や WPF などのフレームワークではどうして元のスレッドに戻ってくるのでしょうか。
GUI フレームワークの同期コンテキスト
Win Form の場合の同期コンテキストを見てみましょう。
Win Form の場合、System.Windows.Forms
名前空間にWindowsFormsSynchronizationContext
というクラスが定義されており、このクラスはSynchronizationContext
を継承しています。
Win Form の UI スレッドは、デフォルトの同期コンテキストではなくこのWindowsFormsSynchronizationContext
のインスタンスを同期コンテキストとして持っています。
この Win Form 用の同期コンテキストによって、UI スレッドからTask
で非同期処理を実行した場合、コールバックがUIスレッドに戻ってきます。
GUI のループと同期コンテキスト
GUI アプリケーションは大きく見れば常にループすることによって成り立っています。例えばゲームなどの場合、1フレームが1回のループと考えていいでしょう。
GUI のフレームワークを使ってアプリケーションを作る場合、多くの場合このループ部分がフレームワークとして隠蔽されているため特にループについて意識することはありませんが、同期コンテキストはこの部分で処理されています。
以下に簡単なループの図を示します。
図のユーザー処理の部分でTask
による非同期処理が実行されたとします。
図の黄色の部分は UI スレッドで実行され、青色の部分が別スレッドで非同期に開始されます。
UI スレッドはawait
の時点でこのメソッドを打ち切って抜け、あとは通常通りに実行が続きます。(GUIのループがUIスレッドで回り続けます)
一方、ワーカースレッドは処理が終わった時点で、元スレッド (UI スレッド) の同期コンテキストにコールバック処理 (await
後のこのメソッドの未実行の処理、つまり赤色の部分) をPost
し、キューにコールバックが登録されます。
その後、ループ中でキューを見張っている UI スレッドによって、コールバックが実行され、まるでTask
をawait
した続きの部分から UI スレッド再開されたように実行されます。
小ネタ
図の右側は、ボタンをクリックした時に非同期処理が走るようなコードですが、await
による中断は、その部分でメソッドを打ち切って残りをコールバックとしているだけなので、UI スレッドはそのまま GUI ループに戻っていきます。つまり、非同期処理 (青の部分) がまだ終わっていない状態で、次回以降のループでもう一度ボタンがクリックされた場合、また別の非同期処理を走らせてしまうことになります。(いわゆる再入)
再入を防ぎたい場合は、図のようにisRunning
のようなフラグを用意しておくことで再入を防げます。
このループは GUI フレームワークによって実装されるため、Post
されたコールバック処理をためておくキューは当然フレームワークによって異なります。そのため、各 GUI フレームワークごとに専用の同期コンテキストを実装する必要があります。
とはいえ、基本的にはコールバック処理をポストする場所が異なるだけです。
SynchronizationContext.Post
メソッドはvirtual
で定義されており、override
することで専用の実装をすることができます。
これによって、Win Form には Win Form 用の同期コンテキスト、WPF には WPF 用の同期コンテキストクラスが定義されています。
例えば Win Form の同期コンテキストWindowsFormsSynchronizationContext
の実装はこれです。
つまり、自分でこの GUI ループ処理を書いて、Task
のコールバックが UI スレッドになるような同期コンテキストクラスを定義すれば、WPF などと同じようにTask
が使えるようになります。
もちろん、ループが開始する前に UI スレッドに対してこの同期コンテキストを紐づけておく必要があります。
// 現在のスレッドの同期コンテキストとして、自作の同期コンテキストを設定
AsyncOperationManager.SetSynchronizationContext(new MySynchronizationContext());
while(true)
{
// GUI スレッドのループ
}
非 UI スレッドでのawait
前述したように、各種 GUI フレームワークの UI スレッドの同期コンテキストは、そのフレームワーク専用の同期コンテキストとなっていますが、非 UI スレッドの同期コンテキストはデフォルトの同期コンテキストです。
そのため、await
後に元のスレッドに戻ってくるのは UI スレッド上の await
のみです。
ワーカースレッド上でのawait
後のコールバックは、元と同じワーカスレッドには戻ってきません。(しかし、UI スレッド以外で元と同じスレッドでないと困るようなことは基本的にないため、問題にはなりませんが)
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)
を必ず付ける必要があります。