「マイクロソフト系技術情報 Wiki」は、「Open棟梁Project」,「OSSコンソーシアム .NET開発基盤部会」によって運営されています。
目次 †
概要 †
用途 †
主に、非同期処理(≠並列処理)を簡単に(同期処理的に)実装するために導入された。
- UIスレッドからのサーバ呼出のハングアップを防止するために使用される。
- または、内部的には、UIスレッドから、時間のかかるバックグラウンド処理
(ネットワーク バインドまたは I/O バインドの処理)を分離する。
- Fire & ForgetやTask.WhenAll?で並列実行処理を実装できるが、
await、Task.Wait()の"待ち合わせ"までがセットになっている仕組みなので、
そもそも、並列実行処理を実装することに特化した基盤ではないことに注意する。
(非同期タスクを作成して待つ処理を同期的に書ける仕組みと考えるべきか。)
- 並列実行処理を実装する場合は、Threadや、ThreadPool?を使用すればイイ。
使い方 †
async/awaitの登場で、非同期処理を同期型処理と、ほぼ変わらない記述で容易に実装可能になった。
- asyncで修飾したTaskを返す非同期メソッドから、awaitステートメントを付与して呼び出す。
- 若しくは、Task.Run()で実行して、Task.Wait()で待ち合わせる。
しかし、デバッグの時は非同期で実行されていることを意識する必要がある。
(しかも、かなり特殊な。同期コンテキスト毎に動作も大きく異なる。)
仕組み †
- 非同期化からのノンブロッキングの復帰には同期コンテキストが使用される。
- 同期コンテキストにはWindowsメッセージングキュー、ThreadPool?などがある。
実装方法 †
タスク分割方法 †
await(Task.Run)を切れ目として、
プログラマが意識して時間のかかるバックグラウンド処理
(ネットワーク バインドまたは I/O バインドの処理)を分割する。
await †
- await前の処理はフォアグラウンド的に実行される。
- awaitで呼び出す非同期メソッドはバックグラウンド的に実行される。
- await後の処理は、コールバックとして実装せずにフォアグラウンドに復帰する。
- 厳密に言うとフォアグラウンドに復帰ではなく、
プログラムのコード上、非同期処理の後続処理に復帰する。
- 例えば、非同期Controllerの非同期処理の後続処理は、
- 非同期スレッド側で実行され、最後にリクエストを受け付けたスレッドにバインドされる。
- この動作は、システム的には、フォアグラウンドに復帰するとは言えない。
Task.Run ~ Task.Wait() †
- Task.Run前の処理はフォアグラウンド的に実行される。
- Task.Run()で呼び出す非同期メソッドはバックグラウンド的に実行される。
- Task.Wait()後の処理は、コールバックとして実装せずして、フォアグラウンドに復帰する。
- Task.Waitが呼ばれるとスレッドはTaskが終わるまで待機する。
- これらの動きの詳細は、同期コンテキストによって異なってくる。
- また、Task.Waitを使用すると、同期コンテキスト上、
実行する非同期Taskの前に、Task.Waitが割り込むとデッドロックになったりする。
非同期メソッドの戻り値 †
void型 †
Fire & Forgetの場合、戻り値は不要。
Task型 †
Awaitableの場合、
- 戻り値の無い非同期メソッドを実装する場合、Task型の戻り値が必要。
Task<T>型 †
Awaitableの場合、
- T型の戻り値の有る非同期メソッドを実装する場合、Task<T>型の戻り値が必要。
Task.FromResult? †
Task.FromResult?を使用すると、
- 完了状態のTaskを生成することができる。
- 戻り値のTaskが長いコード パスを実行することがなくすぐ完了する条件に合致する場合に利用。
詳細については、下記を参照。
非同期メソッドの呼び出し †
await演算子 †
- Task の実行が完了したら、待機せずに、
フォアグラウンドに復帰する風な動きを見せる。
- await 非同期メソッド()
- await Task.Run()
- await演算子の使い方
- 非同期メソッドを呼び出すときに、await演算子を利用する。
- メソッド内でawait演算子を利用する場合、async修飾子でメソッドを修飾する。
- await演算子は、async修飾子の付くメソッドの中で1つ以上記述できる。
Task.Run()・Task.Wait()、Task.WhenAll?()メソッド †
- Task.Run()メソッド
- 非同期メソッドを実行してTask、Task<TResult>を返す。
- Task.Wait()
- Task、Task<TResult>の実行が完了するまで待機する。
- async/awaitと異なり、同期コンテキストで同期せず、
待機(ブロック)した後にフォアグラウンドに復帰する。
- Task.WhenAll?()
- メソッドで複数のタスクを待機するタスクを取得する。
- このTaskをTask.Wait()するとTask毎の
例外をAggregateException?型として取得可能。
非同期メソッドの作り方 †
参考 †
- Task.Start()、Task.Factory.StartNew?()、Task.Run()
詳細 †
ここでは、
- 「非同期メソッドの種類」と
- 「同期コンテキスト」の
組み合わせによって、await後の処理が、
どのように実行されるのかについて説明する。
非同期メソッドの種類 †
非同期メソッドには、2種類ある。
Fire & Forget †
非同期メソッドでもreturn文を書く必要が無い。
戻り値がvoidなので非同期以降がフォアグラウンドに復帰しない。
- 非同期呼び出しで投げっぱなす場合。
- 具体的には、GUIアプリケーションのイベント・ハンドラに適用する場合。
- 上記以外の用途での利用は意味もなく、ハマる原因になるので非推奨。
Awaitable †
非同期メソッドは、Task、Task<T>をリターンする。
戻り値がTask(もしくはTask<T>)なので、await演算子かWait()メソッド
以降に実装された非同期以降がフォアグラウンドに復帰する。
- 非同期呼び出しの呼び出し元で
- awaitで、非同期以降をフォアグラウンドに復帰させる場合。
- Task.Wait()で呼び出し元が非同期メソッドの完了を待機する必要がある場合。
同期コンテキスト †
実行環境によって持つ同期コンテキストの種類が異なる。
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の次の処理が実行されるスレッドは一意ではなくなる。
- ただし、処理自体は、(記述した順に)シーケンシャルに実行される。
- HttpContext?等の保持、非同期処理が全て終わるまで、レスポンスしないよう監視
- 最終的に結果は、リクエストを受け付けたスレッドにバインドされる。
詳しくは、非同期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? †
- 同期コンテキストを使用するか・しないかを制御できる。
ガイドライン †
以下の参考資料から、ガイドラインを纏めてみた。
一般的に †
戻り値がvoidのメソッドを非同期呼び出ししない。 †
- 呼び出し側でタスクの終了を検出することができない。
- タスクで発生した例外を呼び出し側で補足することができない。
- 例外:イベントハンドラはOK(そもそもイベントハンドラ用)。
→ 前項の理由のような挙動でも問題ないが無いため。
Task.Wait() を使う場合は注意する †
- Task.Wait()は、非同期処理を待機するため、デッドロックの原因になり易い。
- 実行する非同期Taskの前に、Task.Waitが割り込むとデッドロックになったりする。
以下、デッドロックのサンプル。
- UIの場合のサンプル
同期コンテキストであるWindowsメッセージングキューに、
Task.Wait、非同期Taskの順でキューイングされるため
前者が後者の完了を待ち続け、後者が何時迄も実行されないため(と思われる)。
- ASP.NETの場合のサンプル
同期コンテキストであるI/O完了ポートに、
Task.Result(≒Task.Wait)、非同期Taskの順でキューイングされるため
前者が後者の完了を待ち続け、後者が何時迄も実行されないため(と思われる)。
ライブラリの場合 †
ざっくり、以下のガイドラインに従う。
- 基本的に同期で実装する。
- 非同期は、async/awaitを使用しない従来の非同期で実装する。
- コールスタックの下位でasyncを使うと、呼出側もasyncの使用が必要になる。
- そのため、一番外側までasyncを使うようにする必要がある。
- しかし、動作を変えることができないケースがあるので、
Task.Waitやその他のブロック手段を使って同期を取るしかなくなる。
- async/awaitを使用する場合、
- Taskを返すだけにして、Task.Runは使わないようにする。
- ライブラリ内でawaitする場合は、ConfigureAwait?(false)を使う。
詳しくは、下記を参照のこと。
ライブラリ内でTask.Runを使わない †
- 理由:
- ライブラリがグローバル共有リソースであるThreadPool?を使用することになる。
- ライブラリは、実行コンテキストが不明なので、ThreadPool?利用の決定は、
ライブラリ開発者ではなくアプリケーション開発者がするべき。
- ライブラリが非同期メソッドを提供するのはネイティブ非同期メソッドを使用する場合。
- ネイティブ非同期メソッドはスレッドプールを使った別スレッドによる非同期処理を目的としていない。
- I/Oなどの待ちに対してスレッドを空けて同時実効性を高めることが目的。
- サーバでのTask.Run
- サーバでTask.Runを使わない。
- 理由:
- Task.Runはスケーラビリティが求められるサーバでは不適切
- I/Oバウンドの場合(CPUバウンドでない場合)だけ、非同期tタスクを定義する意味があるが、
この決定はライブラリ開発者ではなくアプリケーション開発者がするもの。
- クライアントでのTask.Run
- クライアントでも、Task.Runを使わない。
- 理由:
- クライアント側ではTask.Runを使う理由がたくさんある。
- しかし前述にあるように、実行コンテキストが不明なので、ライブラリ内でTask.Runを使わない。
- 例外: マルチスレッドとWinJS
WinJSは新しいバックグラウンドスレッドを作ることができないので、かわりにライブラリ側で作る必要がある。
- 例外: Stream.ReadAsync?
- ある種のストリームはこれをサポートしない。
- サポートされない場合は、基底クラス(Steamクラス)でTask.Runを実行するのが最も安全な方法
Waitを使う同期メソッドで非同期メソッドをラップしない †
- 同期バージョンの方が非同期バージョンより高速(と言う仮定)。
非同期バージョンより高速な同期バージョンを提供できない場合、
- 両方のバージョンを提供する(=ラップを提供する)理由が無い。
- 非同期バージョンを呼び出すときにTask.Waitを使って同期をとるほうが良い。
- 同期バージョンは、UIスレッドで実行しても安全(と言う仮定)。
非同期メソッドをラップした同期メソッドがTask.Waitを使っていた場合、デッドロックが発生する可能性がある。
- 従って、推奨は、
- メソッドが同期処理を行うなら、同期バージョンだけを提供する。
- メソッドが非同期処理を行うなら、
- 非同期バージョンだけを提供する。
- 高速に動作するデッドロックを起こさない同期メソッドのみ追加で定義可能。
デッドロックと同期コンテキスト †
- 基本的にはライブラリ内でawaitする場合はConfigureAwait?(false)する。
- 性能的に早くなる。
- UIの場合の同期コンテキストであるWindowsメッセージングキューなど、
同期コンテキストによっては、デッドロックさせる可能性が高くなる。
- ConfigureAwait?(false)すると元のスレッド(主にUIスレッド)には戻らない。
必要に応じて、同期コンテキストによる動作スレッドの切り替えを行う。
// UIスレッドの同期コンテキストをキャッシュする
SynchronizationContext syncContext = SynchronizationContext.Current;
//.ConfigureAwait(false)でUIスレッドに戻さない
await HeavyWorkAsync().ConfigureAwait(false);
// UIスレッドの同期コンテキストにディスパッチする。
syncContext.Post(state =>
{
// 何からの処理。
}, null);
性能についての考察 †
- 実行コンテキストをコピーする。
- ログイン・ユーザやカルチャ情報など偽装をする場合、
CallContext?.SetLocalData?を使用して、実行コンテキストをコピーする。
- この処理は非同期呼び出しに少量の性能コストを追加する。
- ループ内で呼び出さない。
- asyncを使ったメソッドはTaskの生成やTaskの実行管理のためのコストがかかる。
- 従って、ライブラリのユーザにはループ内で呼び出さないように注意喚起する。
メモリについての考察 †
非同期メソッドの呼び出しは次の3つのメモリ確保処理を生む。
- ◯:ローカルの変数を保存するためのステートマシン
- ◯:継続のためのデリゲート
- 結果を返すためのタスク
◯が付与された、ステートマシンとデリゲートはawaitキーワードが
ランタイムに現れたときに作成されるため、同期処理と比べるとコストになる。
参考 †
落とし穴 †
ガイドライン †
参考 †
Tags: :.NET開発