(C#) 同期コンテキストと Task, async/await
.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 スレッドのループ }
その他
前述したように、各種 GUI フレームワークの UI スレッドの同期コンテキストは、そのフレームワーク専用の同期コンテキストとなっていますが、非 UI スレッドの同期コンテキストはデフォルトの同期コンテキストです。
そのため、await
後に元のスレッドに戻ってくるのは UI スレッド上の await
のみです。
ワーカースレッド上でのawait
後のコールバックは、元と同じワーカスレッドには戻ってきません。(しかし、UI スレッド以外で元と同じスレッドでないと困るようなことは基本的にないため、問題にはなりませんが)
<追記>
この続きの記事書きました