Open棟梁Project - マイクロソフト系技術情報 Wiki
目次 †
概要 †
awaitの意味は、スレッドを留めずにCallbackを待つということらしい。
async/awaitはそういう動きをする、非同期プログラムを容易に記述できる。
- async/awaitの登場で、マルチスレッド処理として実装しなくても、
非同期処理を同期型処理と、ほぼ変わらない記述で実装可能になった。
- しかし、その実態はとても複雑、詳しくはコチラを参照。
- 内部がどう動いているか詳しく把握せんとシステムは安定しない、
- 中身をシッカリ理解して使用している人が少ないので注意。
非同期処理の実装史 †
1. Thread、ThreadPool? †
従来の、マルチスレッド・プログラミング。
- スレッドの並列実行はOSが裏で無意識にしてくれていた。
- タイムスライスで細切れ/ラウンドロビンで論理的に並列実行。
- CPUのコア数に応じて、物理的に並列実行。
- しかし、以下の処理は意識的に実装する必要があった。
- 非同期処理をスレッド関数として分離して実装する。
- スレッド関数を作成したワーカースレッドに渡す。
- スレッド関数の結果をメインスレッドで待ち合わせる。
2. APM、EAP (Control.Invoke、.BeginInvoke?) †
「Windowsメッセージングキュー(Control.Invoke、.BeginInvoke?)」による方式。
Open棟梁の「非同期呼出フレームワーク」がこの方式で実装されている。
async/awaitは、TAP(Task-based Asynchronous Pattern)の方式で実装されている。
用途 †
- 主に、非同期処理(≠並列処理)を実装するために導入された。
- UIスレッドからのサーバ呼出のハングアップを防止するために使用される。
- 内部的には、UIスレッドから、時間のかかるバックグラウンド処理
(ネットワーク バインドまたは I/O バインドの処理)を分離する。
- この仕組みは、Webサーバのスレッド枯渇を防ぐ非同期Controllerなどにも応用されている。
使い方 †
- asyncで修飾したTaskを返す非同期メソッドから、awaitステートメントを付与して呼び出す。
- 若しくは、Task.Run()で実行して、Task.Wait()で待ち合わせる。
余談? †
async/awaitの夫々の意味。
- asyncは、呼び出し元と同じ同期コンテキストで実行されることを示す。
- asyncをawaitすると、スレッドを止めずにコールバックを待つことができる。
この動作は想像し難いが、具体的には、
- 同じ同期コンテキスト(Windowsメッセージングキュー)に
- コールバックを順番に並べる(Control.BeginInvoke?()的な)。
というイメージ。
- ・・・結局、APM、EAPを使ってるじゃねーか的な。
- 同期コンテキストの種類によって、動きも異なる。
なので、言葉尻で動作を想像し難い。
タスク分割方法 †
await(Task.Run)を切れ目として、
プログラマが意識して時間のかかるバックグラウンド処理
(ネットワーク バインドまたは I/O バインドの処理)を分割する。
- await
- await前の処理はフォアグラウンドで実行される。
- awaitで呼び出す非同期メソッドはバックグラウンドで実行される。
- await後の処理は、コールバックとして実装せずにフォアグラウンドに復帰する。
- 厳密に言うとフォアグラウンドに復帰ではなく、
プログラムのコード上、非同期処理の後続処理に復帰する。
- 例えば、非同期Controllerの非同期処理の後続処理は、
非同期スレッド側で実行され、最後にリクエストを受け付けたスレッドにバインドされる。
この動作は、システム的には、フォアグラウンドに復帰するとは言えない。
- Task.Run ~ Task.Wait()
- Task.Run前の処理はフォアグラウンドで実行される。
- Task.Run()で呼び出す非同期メソッドはバックグラウンドで実行される。
- Task.Wait()後の処理は、コールバックとして実装せずして、フォアグラウンドに復帰する。
(Task.Waitが呼ばれるとスレッドはasyncが終わるまで待機する。)
仕組み †
- 非同期化は、同期コンテキスト(Windowsメッセージングキュー、スレッド)などを使用して行われる。
- 並列実行処理を実装するための基盤ではない
- Fire & ForgetやTask.WhenAll?で並列実行処理を実装できるが、
await、Task.Wait()の"待ち合わせ"までがセットになっている仕組みなので、
そもそも、並列実行処理を実装することに特化した基盤ではないことに注意する。
(非同期タスクを作成して待つ処理を同期的に書ける仕組みと考えるべきか。)
- 並列実行処理を実装する場合は、Threadや、ThreadPool?を使用するようにする。
使い方 †
- async/awaitの登場で、同期型処理と、ほぼ変わらない記述で実装可能になった。
- しかし、デバッグの時は非同期で実行されていることを意識する必要がある。
非同期メソッドの戻り値 †
void型 †
Fire & Forgetの場合、戻り値は不要。
Task型 †
Awaitableの場合、
Task<T>型 †
Awaitableの場合、
Task.FromResult? †
Task.FromResult?を使用すると、
- 完了状態のTaskを生成することができる。
- 戻り値のTaskが長いコード パスを実行することがなくすぐ完了する条件に合致する場合に利用。
詳細については、下記を参照。
非同期メソッドの呼び出し †
Task.Run()、Task.Wait()メソッド †
- Task.Run()
- 非同期メソッドを実行してTask、Task<TResult>を返す。
- Task.Wait()
- Task、Task<TResult>の実行が完了するまで待機する。
Task.WhenAll?() †
- Task.WhenAll?()メソッドで複数のタスクを待機するタスクを取得する。
- このTaskをTask.Wait()するとTask毎の例外をAggregateException?型として取得可能。
await演算子 †
- Task の実行が完了するまで待機する。
- await 非同期メソッド()
- await Task.Run()
- await演算子の使い方
- 非同期メソッドを呼び出すときに、await演算子(後述)を利用する。
- メソッド内でawait演算子(後述)を利用する場合、async修飾子でメソッドを修飾する。
- await演算子は、async修飾子の付くメソッドの中で1つ以上記述できる。
詳細 †
ここでは、
- 「非同期メソッドの種類」と
- 「同期コンテキスト」の
組み合わせによって、await後の処理が、
どのように実行されるのかについて説明する。
非同期メソッドの種類 †
非同期メソッドには、2種類ある。
- Fire & forget
戻り値がvoidの「待てない」メソッド
- Awaitable
戻り値がTask(もしくはTask<T>)の、await演算子かWait()メソッドで「待てる」メソッド
Fire & Forget †
戻り値がvoidの「待てない」メソッド
- 非同期呼び出しで投げっぱなす場合。
呼び出し元が非同期メソッドの完了を待機する必要がない場合
(この場合、非同期メソッドでもreturn文を書く必要が無い)。
- 具体的には、GUIアプリケーションのイベント・ハンドラに適用する場合。
(それ以外の用途での利用はハマる原因になるらしいので非推奨らしい)
Awaitable †
戻り値がTask(もしくはTask<T>)の、await演算子かWait()メソッドで「待てる」メソッド
- 非同期呼び出しの呼び出し元でTask.Wait()で待ち合せする場合。
呼び出し元が非同期メソッドの完了を待機する必要がある場合
(この場合、非同期メソッドは、Task、Task<T>をリターンする)。
- 上記のGUIアプリケーションのイベント・ハンドラ以外に適用する。
同期コンテキスト †
実行環境によって持つ同期コンテキストの種類が異なる。
GUIアプリケーション †
GUIアプリケーションでの同期コンテキストは
「Windowsメッセージングキュー(Control.Invoke、.BeginInvoke?で使う)」になる。
- Fire & Forget
GUIアプリケーションのawaitの次の処理は、
同期コンテキストのControl.BeginInvoke?によって、
UIスレッド上でシーケンシャルに実行される。
- Awaitable
該当なし?Control.Invokeでは?
Consoleアプリケーション †
Consoleアプリケーションでの同期コンテキストは「null」になる。
- Fire & Forget
非推奨(Threadや、ThreadPool?を使用すればイイ)
- Awaitable
- Consoleアプリケーション内のawaitの次の処理が実行されるスレッドは一意ではなくなる。
- ただし、処理自体は、(記述した順に)シーケンシャルに実行される。
ThreadPool? †
Task.Runを使用した場合の同期コンテキストは、
マルチスレッド環境下の「ThreadPool?」になる。
- Fire & Forget
非推奨(Threadや、ThreadPool?を使用すればイイ)
- Awaitable
- Task.Run内のawaitの次の処理が実行されるスレッドは一意ではなくなり、
- 且つ、Task.Run内のawaitの次の処理は、UIスレッドに戻らなくなる。
- ただし、処理自体は、(記述した順に)シーケンシャルに実行される。
ASP.NET †
ASP.NETアプリケーションでの同期コンテキストは
マルチスレッド環境下の「System.Threading.SynchronizationContext?.Current」になる。
- Fire & Forget
非推奨(Threadや、ThreadPool?を使用すればイイ)
- Awaitable
- ASP.NETアプリケーションのawaitの次の処理が実行されるスレッドは一意ではなくなる。
- ただし、処理自体は、(記述した順に)シーケンシャルに実行される。
- 最終的に結果は、リクエストを受け付けたスレッドにバインドされる。
詳しくは、非同期Controllerを参考にする。
この辺の
スタック・トレースを見ると、
- 「SynchronizationContext?」と
- 「AsyncControllerActioninvoker?」は
同じ事らしいと解る。
並列実行 †
- GUI以外の同期コンテキストでFire & Forgetを実行した場合(非推奨)や、
- Task.WhenAll?で複数のTaskをTask.Wait()した場合(ThreadPool?で実行される)では、
並列実行を始めるので、
特に、Consoleアプリケーションの同期コンテキスト(null)の下では、
新規に同期処理の実装が必要になることがある。
同期 †
async/awaitは非同期呼び出しで投げっぱなした後に、
同期コンテキストにより同期される方式のため、同期をあまり考慮していないが、
同期コンテキストによっては、同期を行う必要があるため、以下に注意する。
仕組みから †
スレッドを使用した処理を記述しないが、
分割されたタスクは、
- 同一スレッドで動作することも
- 別スレッドで動作することもある。
このため、一連の処理が(分割されたタスクが)、
- 異なるスレッドで実装される保証は無い。
- 同じスレッドで実行される保証も無い。
スレッド同期ツールキットは使用不可 †
従って、スレッド同期のlock等は無意味。
従来のスレッド同期ツールキットは使用不可。
lock/mutex/semaphoreはtaskで全て使用禁止 †
旧プログラムで、
- スレッド・アフィニティのあるロック機構(lock/mutex/semaphore)
を使用してコードブロックをロックしている場合に、
- awaitを使用して追加開発をしたい場合、
(await演算子を含むコードブロックをロックしたい場合)、
SemaphoreSlim?を使用するように修正が必要になる。
WaitFor?[Single|Multi]Objectは例外的に使用可 †
Win32の待機関数のWaitFor?[Single|Multi]Objectは例外的に使用可。
これは、これらの待機関数は、
- スレッドアフィニティではなく、
- 「状態変化」(ノンシグナル状態からシグナル状態への変化)を
待つ関数であるためと考える。
その他 †
進捗報告 †
上記の仕組みで動いているとすると、進捗報告をどう実装するかが?であるが、
以下を見ると、Progressクラス、IProgress<T>インターフェースを使用するらしい事が解る。
詳しい仕組みは不明だが、GUI上で動作しているため、
同期コンテキストのControl.Invoke、.BeginInvoke?を使用しているものと思われる。
ContinueWith? †
- 複数のタスクを継続に連結し、時間のかかる同期コンテキストを経由しない。
ConfigureAwait? †
- 同期コンテキストを使用するか・しないかを制御できる。
ガイドライン †
以下の参考資料を纏めてみた。
一般的に †
UIで Task.Wait() を使う場合は注意する †
- Task.Wait()は、非同期処理を待つため、UIではデッドロックの原因になり易い。
- 例えば、非同期処理に、UIへの結果反映処理があったらデッドロックになる。
- 通常のControl.Invoke、.BeginInvoke?もUIスレッド側から待つことはしない。
- UIスレッド側からは投げっぱなし、Backgroundスレッドから結果の反映用の処理をUIスレッドにキューイングする。
戻り値がvoidのメソッドを非同期呼び出ししない。 †
- 呼び出し側でタスクの終了を検出することができない。
- タスクで発生した例外を呼び出し側で補足することができない。
- 例外:イベントハンドラーはOK。→ 前項の理由のような挙動でも問題ない。
ライブラリの場合 †
ライブラリ内でTask.Runを使わない †
- 理由:
- ライブラリがグローバル共有リソースであるスレッドプールを使用する。
- ライブラリは、実行コンテキストが不明なので、スレッドプール利用
の決定は、ライブラリ開発者ではなくアプリケーション開発者がする。
- サーバでのTask.Run
- サーバでTask.Runを使わない
- 理由:
- Task.Runはスケーラビリティが求められるサーバでは不適切
- 遅延減少のために、Task.Runを使うのは意味がある。
しかし、この決定はライブラリ開発者ではなくアプリケーション開発者がするもの。
- クライアントでのTask.Run
- クライアント側ではTask.Runを使う理由がたくさんある。
- しかし前述にあるように、ライブラリ内でTask.Runを使わない。
- 例外
- 例外: マルチスレッドとWinJS
WinJSは新しいバックグラウンドスレッドを作ることができないので、かわりにライブラリ側で作る必要がある。
- 例外: Stream.ReadAsync?
- ある種のストリームはこれをサポートしない。
- サポートされない場合は、基底クラス(Steamクラス)でTask.Runを実行するのが最も安全な方法
Waitを使う同期メソッドで非同期メソッドをラップしない †
これは以下の様なユーザの過程に基づくため。
- 非同期バージョンより高速な同期バージョンの方が高速である。
非同期バージョンより高速な同期バージョンを提供できない場合、
- 両方のバージョンを提供する理由が無い。
- 非同期バージョンを呼び出すときにTask.Waitを使って同期をとるほうが良い。
- 非同期バージョンを、UIスレッドで実行しても安全である。
非同期メソッドをラップした同期メソッドがTask.Waitを使っていた場合、デッドロックが発生する可能性がある。
- 推奨
- メソッドが同期処理を行うなら、同期バージョンだけを提供する。
- メソッドが非同期だったら、非同期バージョンだけを提供する。
デッドロックとSynchronizationContext? †
性能 †
- ExecutionContext?
- ログインユーザやカルチャ情報など偽装をする場合、CallContext?.SetLocalData?を使用する。
- この処理は非同期呼び出しに少量の性能コストを追加する。
- ループ
- asyncを使ったメソッドはTaskの生成やTaskの実行管理のためのコストがかかる。
- 従って、ライブラリのユーザにはループ内で呼び出さないように注意喚起する。
メモリ †
非同期メソッドの呼び出しは次の3つのメモリ確保処理を生む。
- ◯:ローカルの変数を保存するためのステートマシン
- ◯:継続のためのデリゲート
- 結果を返すためのタスク
ステートマシンとデリゲートはawaitキーワードがランタイムに
現れたときに作成されるため、同期処理と比べるとコストになる。
参考 †
落とし穴 †
参考 †
参考 †