読者です 読者をやめる 読者になる 読者になる

async/await と SynchronizationContext (1)

C# 非同期 ASP.NET WPF Windows Forms

前回の記事に続き、非同期処理シリーズの第 2 弾。
今回は SynchronizationContext について。

SynchronizationContext は、.NET Framework 2.0 から登場したクラスです。「同期コンテキスト」と訳される場合もあります。
これが何かと言うと、誤解を恐れずに大雑把に言えば、「スレッドをまたがる際の問題をいい感じに処理してくれるクラス」です。
いやまぁ、ふわっとしすぎているのは分かっているんですが、実際にどういう処理をするかは派生クラスによるので、こういう言い方しかできないんです。

WPFWindows Forms の場合

WPF にも Windows Forms にも共通の制限として、ウィンドウやテキスト ボックスのような UI コントロールは、それを生成したスレッドからしか操作できないというものがあります。
ワーカー スレッドなど、他のスレッドからコントロールのプロパティを操作したりメソッドを実行しようとすると、例外が発生します。*1
例えば、.NET 1.x の非同期処理(APM)では、非同期処理が完了したときのコールバック関数はワーカー スレッドで実行されるため、このコールバック関数内で UI コントロールを操作することはできません。
そのため、WPF ならば Dispatcher.InvokeWindows Forms ならば Control.Invoke といったメソッドを使って、UI スレッドに処理をさせる必要があります。

WPF のサンプルを以下に示します。
XAML は貼りませんが、Window 上にボタンと WebBrowser が乗っている状態を想像してください。
ボタンを押したらこのブログを WebBrowser に表示するだけのシンプルなプログラムです。
デバッグ実行すると、Visual Studio の「出力」ウィンドウに、異なるスレッド ID が出力されるのが確認できます。


gist0c803661e991ea8b31d4

.NET 2.0 の EAP だと、ちょっと事情が変わります。
.NET 2.0 からは SynchronizationContext があるからです。


gist356d870b6c41ff5f5a2e

EAP では非同期処理が完了したことを通知するイベントは SynchronizationContext を通して呼ばれます。
こうすることにより、イベントが UI スレッドで実行されることが保証されます。
そのため、イベント ハンドラーの中では、スレッドを気にすることなく UI コントロールを操作することができます。

SynchronizationContext は、スレッドごとにインスタンスを持っています。
現在のスレッドに関連付けられている SynchronizationContext は、SynchronizationContext.Current で取得することができます。
WPFWindows Forms の場合、UI スレッドが SynchronizationContext を持っています。
EAP に対応したクラスは、ワーカー スレッドを立ち上げる前に、UI スレッドの SynchronizationContext を取得して保持しておきます(「SynchronizationContext をキャプチャーする」とも言います)。
ワーカー スレッドでの処理が終わったら、キャプチャーしておいた SynchronizationContext を使って SynchronizationContext.Post などのメソッドを呼ぶことで、後続の処理を UI スレッドで実行することができます。

こういうことができるのは、WPFWindows Forms といった Windows の UI プラットフォームでは、時間のかかるビジネス ロジックはワーカー スレッドで行い、UI スレッドは UI 上で発生するイベントを待ち受けるために、基本的にはずっと待機しているという作りになっているからです。

SynchronizationContext を持たないスレッドもあります。
ワーカー スレッドや、コンソール アプリケーション、PowerShell 等のスレッドには SynchronizationContext がありません。

ここで注意して頂きたいのは、SynchronizationContext は、必ずしも UI スレッドで処理をするためのものではないということです。
WPF であれば DispatcherSynchronizationContextWindows Forms であれば WindowsFormsSynchronizationContext という派生クラスがいて、実際に処理をしているのはこれらの派生クラスです。
それぞれのプラットフォームにおいて、「スレッドをまたがる際の問題をいい感じに処理する」とはどういうことかと考えると、その答えが「UI スレッドで処理をする」ということだったということです。
つまり、プラットフォームによって「いい感じの処理」の内容は異なるわけです。

ASP.NET の場合

ASP.NET にも SynchronizationContext はあります。AspNetSynchronizationContext というのですが、internal なクラスなのでドキュメントはありません。

ASP.NET では、サーバーに対するリクエストは一旦キューに貯められ、そこからリクエスト スレッドによって拾い上げられて処理されます。
このリクエスト スレッドが SynchronizationContext を持っています。

WPF 等の場合と違うのは、キューにリクエストを入れたスレッドは、すぐに終了してしまって、リクエスト スレッドの処理が始まる時には既にいないということです。
待機している要求元のスレッドはいないので、元のスレッドに処理をさせることはできません。
さらに言えば、非同期処理が完了する頃には、リクエスト スレッドも終了していなくなってしまっています。*2
ここからも、SynchronizationContext は、元のスレッドで処理をさせるものとは限らないということがわかります。

では ASP.NET の SynchronizationContext は何をするのかと言うと、HttpContext.Current を適切に設定するものらしいです。
HttpContext.Current は、リクエスト スレッドが現在処理中のリクエストに関する情報を持っています。
リクエスト スレッドから非同期処理が呼ばれてワーカー スレッドが作られても、ワーカー スレッドも同じリクエストを処理していると言えるわけですから、リクエスト情報をスレッド間で共有しているわけです。
ThreadPool 等を使って起動したワーカー スレッドでは、SynchronizationContext がないため、このプロパティは null になっています。
しかし、WebClient.DownloadStringAsync 等の EAP メソッドを使うと、イベントが発生するスレッドはリクエスト スレッドではないにもかかわらず、HttpContext.Current は null ではありません。


gist89f39a0f455568ccf105

このサンプルは場合によってはエラーになる場合がありますが、そこを正しく動かすのは本記事の趣旨ではありませんので割愛します。

つまり、ASP.NET の場合、「ワーカー スレッド上でも HttpContext.Current を正しく設定する」ということが、AspNetSynchronizationContext が行う「いい感じの処理」なのです。

また、非同期処理はワーカー スレッドを立ち上げたら、それを呼んだメソッドは早々に終了してしまいますが、ワーカー スレッドが完了するまで HTTP レスポンスを返すのを遅延させるというのも AspNetSynchronizationContext の仕事のようです。

ASP.NET と非同期の話に関しては、やや古い記事になりますが、こちらなどをお読み頂くと理解が深まるかと。
ASP.NET で非同期 (Async) を乗りこなす
ASP.NET の非同期でありがちな Deadlock を克服する

まとめ

いやー、SynchronizationContext の話が長くなりすぎて、async/await の話題まで行きませんでした。
次回は async/await の話から入りましょう。

ところで、記事中で SynchronizationContext って何回言ったでしょうか?

*1:.NET 2.0 以降。1.x では発生しません。

*2:実際には消滅せず、スレッド プールに返されるのですが、他のリクエストを処理している可能性もあるため、コールバックを関数を実行することはできません。