マイクロソフト系技術情報 Wiki」は、「Open棟梁Project」,「OSSコンソーシアム .NET開発基盤部会」によって運営されています。

目次

概要

はじめに

  • async/awaitの登場で、マルチスレッド処理として実装しなくても、
    非同期処理を同期型処理と、ほぼ変わらない記述で容易に実装可能になった。
  • awaitの意味は、
    • スレッドを止めずノンブロッキングで
    • 非同期から同期に復帰する

という意味らしい。

  • しかし、その実態はとても複雑(詳しくはコチラを参照)。
    • 内部がどう動いているか詳しく把握しないとシステムは安定しない。
    • 中身をシッカリ理解して使用している人が少ないので注意が必要。

用途

主に、非同期処理(≠並列処理)を簡単に(同期処理的に)実装するために導入された。

  • UIスレッドからのサーバ呼出のハングアップを防止するために使用される。
  • または、内部的には、UIスレッドから、時間のかかるバックグラウンド処理
    (ネットワーク バインドまたは I/O バインドの処理)を分離する。
  • Webサーバのスレッド枯渇を防ぐ非同期Controllerなどにも応用されている。
  • 並列実行処理を実装するための基盤ではない。
  • Fire & ForgetやTask.WhenAll?で並列実行処理を実装できるが、
    await、Task.Wait()の"待ち合わせ"までがセットになっている仕組みなので、
    そもそも、並列実行処理を実装することに特化した基盤ではないことに注意する。
    (非同期タスクを作成して待つ処理を同期的に書ける仕組みと考えるべきか。)
  • 並列実行処理を実装する場合は、Threadや、ThreadPool?を使用すればイイ。

使い方

async/awaitの登場で、非同期処理を同期型処理と、ほぼ変わらない記述で容易に実装可能になった。

  • asyncで修飾したTaskを返す非同期メソッドから、awaitステートメントを付与して呼び出す。
  • 若しくは、Task.Run()で実行して、Task.Wait()で待ち合わせる。

しかし、デバッグの時は非同期で実行されていることを意識する必要がある。
(しかも、かなり特殊な。同期コンテキスト毎に動作も大きく異なる。)

仕組み

  • 非同期化からのノンブロッキングの復帰には同期コンテキストが使用される。
  • 同期コンテキストにはWindowsメッセージングキュー、ThreadPool?などがある。
  • 仕組みの詳細についてはコチラを参照。

async/await]]は、TAP(Task-based Asynchronous Pattern)の方式で実装されている。

async/awaitは、

  • メッセージ(番号)のような制約のある単純固定長値のキューではなく、
    実行コードの任意のコード断片そのものをキュー(同期コンテキスト)を使用して繋いでいる。
  • .NET 3.5用のasync/await互換NuGetパッケージでは、
    .NET 4のTask互換クラスを、内部をBeginInvoke?等で実装して、async/awaitを使えるようにしていた。

余談

async/awaitの夫々の意味。

  • async修飾子
    asyncは、呼び出し元と同じ同期コンテキストで実行されることを示す。
  • await演算子
    asyncをawaitすると、スレッドを止めずに同期コンテキストで同期する。

この動作は想像し難いが、具体的には、

  • 同じ同期コンテキスト(Windowsメッセージングキュー)に
  • コールバックを順番に並べる(Control.BeginInvoke?()的な)。

というイメージ。

  • ・・・結局、APM、EAPと同じ技術(同期コンテキスト)を使っている。
  • なお、同期コンテキストの種類によって、動きも異なる。
    • 同期コンテキストはキュー的なもので実装されている。
      例えば、前述の、
      • Windowsメッセージングキュー、
      • ThreadPool?
      • I/O 完了ポート
      • , etc.

実装方法

タスク分割方法

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型の戻り値が必要。
  • 基本的に、returnを書く必要はない。
  • しかし、非同期メソッド内で非同期処理を呼び出さない場合、
    以下のように、完了状態のTaskを生成するreturnを書く必要がある。
    await Task.FromResult(0);
    await Task.FromResult(default(object));

Task<T>型

Awaitableの場合、

  • T型の戻り値の有る非同期メソッドを実装する場合、Task<T>型の戻り値が必要。
  • 戻り値はTask<T>型だが、awaitした後、Tの型の値をreturnするように実装する。
    int ret = await XXXXAsync();
    return ret;
  • しかし、非同期メソッド内で非同期処理を呼び出さない場合、
    以下のように、完了状態のTask<T>を生成するreturnを書く必要がある。
    await Task.FromResult(new 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?型として取得可能。

非同期メソッドの作り方

参考

詳細

ここでは、

  • 「非同期メソッドの種類」と
  • 「同期コンテキスト」の

組み合わせによって、await後の処理が、
どのように実行されるのかについて説明する。

非同期メソッドの種類

非同期メソッドには、2種類ある。

  • Awaitable
    戻り値がTask(もしくはTask<T>)のメソッド

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?」は

同じ事らしいと解る。

なお、恐らく、これらの同期コンテキストは、
I/O完了ポート(IOCP)= ノンブロッキングI/Oであると思われる。

並列実行

  • GUI以外の同期コンテキストでFire & Forgetを実行した場合(非推奨)や、
  • Task.WhenAll?で複数のTaskをTask.Wait()した場合(ThreadPool?で実行される)では、

並列実行を始めるので、
特に、Consoleアプリケーションの同期コンテキスト(null)の下では、
新規に同期処理の実装が必要になることがある。

同期

async/awaitは非同期呼び出しで投げっぱなした後に、
同期コンテキストにより同期される方式のため、同期をあまり考慮していないが、
同期コンテキストによっては、同期を行う必要があるため、以下に注意する。

仕組みから

スレッドを使用した処理を記述しないが、

分割されたタスクは、

  • 同一スレッドで動作することも
  • 別スレッドで動作することもある。

このため、一連の処理が(分割されたタスクが)、

  • 異なるスレッドで実装される保証は無い。
  • 同じスレッドで実行される保証も無い。

スレッド同期ツールキットは使用不可

従って、スレッド同期のlock等は無意味。
従来のスレッド同期ツールキットは使用不可。

lock/mutex/semaphoreはtaskで全て使用禁止

旧プログラムで、

  1. スレッド・アフィニティのあるロック機構(lock/mutex/semaphore)を使用してコードブロックをロックしている場合に、
  2. awaitを使用して修正変更をしたい場合(await演算子を含むコードブロックをロックしたい場合)、

スレッド・アフィニティのないロック機構を使用する。

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を使わない

  • アンチパターン
    public static async Task FetchFileAsync(int fileNum)  
    {    
        await Task.Run(() =>    
        {    
            var contents = IO.DownloadFile();    
            Console.WriteLine("Fetched file #{0}: {1}", fileNum, contents);    
        });    
    }
  • 理由:
    • ライブラリがグローバル共有リソースである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開発


トップ   編集 凍結 差分 バックアップ 添付 複製 名前変更 リロード   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2018-02-27 (火) 13:41:57 (205d)