Skip to content

MS_AsyncAwait

nishi_74322014 edited this page Aug 21, 2026 · 1 revision

async/await

概要

はじめに

  • async/await の登場で、マルチスレッド処理として実装しなくても、
    非同期処理を同期型処理と、ほぼ変わらない記述で容易に実装可能になった。

  • await の意味は、

    • スレッドを止めずノンブロッキングで
    • 非同期から同期に復帰する

という意味らしい。

  • 実際、async/await はそういう動きをする。

    • スレッドや Windows メッセージングキューで Task 化される。
      昔懐かしい、ノンプリエンプティブ・マルチタスク(Win3.1)のようなイメージ。

    • しかし、その実態はとても複雑(詳しくはコチラを参照)。

      • 内部がどう動いているか詳しく把握しないとシステムは安定しない。
      • 中身をシッカリ理解して使用している人が少ないので注意が必要。

補足(一言でいうと「継続の分割」): async/await の本質は、
コンパイラがメソッドをステート マシンに書き換えることにある。

【書いたコード】
   var a = await FooAsync();
   Console.WriteLine(a);

【コンパイラが生成するもの】
   ① FooAsync() を呼ぶ
   ② まだ終わっていなければ、「続き」をコールバックとして登録して return
      (=ここでスレッドは解放される)
   ③ 完了したら、「続き」(Console.WriteLine)が実行される

原文が「ノンプリエンプティブ・マルチタスクのようなイメージ」と
言っているのは的確で、OS がスレッドを横取りするのではなく、
await の位置でプログラム側が自発的に制御を手放す
点が同じである。

重要な帰結: await待たない
その場で return して、続きを後で実行する
だからスレッドが空き、スケーラビリティが上がる。

用途

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

  • UI スレッドからのサーバ呼出のハングアップを防止するために使用される。

  • または、内部的には、UI スレッドから、時間のかかるバックグラウンド処理
    (ネットワーク バインドまたは I/O バインドの処理)を分離する。

  • Web サーバのスレッド枯渇を防ぐ非同期 Controllerなどにも応用されている。

  • 並列実行処理を実装するための基盤ではない。

    • Fire & Forgetや Task.WhenAll で並列実行処理を実装できるが、
      await、Task.Wait() の "待ち合わせ" までがセットになっている仕組みなので、
      そもそも、並列実行処理を実装することに特化した基盤ではないことに注意する。
      (非同期タスクを作成して待つ処理を同期的に書ける仕組みと考えるべきか。)

    • 並列実行処理を実装する場合は、Thread や、ThreadPool を使用すればイイ。

補足(「非同期 ≠ 並列」の要点): 原文が繰り返し強調している通り、
ここが最大の誤解ポイントである。

非同期(async/await) 並列(Parallel / Thread)
目的 待ち時間にスレッドを解放 CPU を使い切る
対象 I/O バウンド(DB、HTTP、ファイル) CPU バウンド(計算)
スレッド むしろ減る(1 本も要らないことがある) コア数だけ使う
代表 await httpClient.GetAsync() Parallel.ForTask.Run

「async は速くするための道具ではない」
1 件の処理は、むしろステート マシンのぶんわずかに遅くなる
速くなるのは**同時に捌ける数(スループット)**である。

現在の並列処理の道具は次の通り。

用途 手段
CPU バウンドの分割 Parallel.For / Parallel.ForEach
非同期処理の並列度制御 Parallel.ForEachAsync(.NET 6+)
複数の非同期を待ち合わせ Task.WhenAll
生の並列 Thread(現在はほぼ使わない)

なお Task.Run は「並列」側の道具であり、
I/O 待ちを Task.Run で包むのは無意味(むしろ有害)である。

使い方

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 を使えるようにしていた。

補足(非同期パターンの世代): .NET の非同期には 3 世代ある。

世代 名称
1 APM(Asynchronous Programming Model) BeginXxx / EndXxx
2 EAP(Event-based Asynchronous Pattern) XxxAsync + XxxCompleted イベント
3 TAP(Task-based Asynchronous Pattern) Task / Task<T> を返す

現在は TAP のみを使う。APM / EAP は既存 API の互換のために残る。

余談

async/await の夫々の意味。

  • async 修飾子
    async は、呼び出し元と同じ同期コンテキストで実行されることを示す。

  • await 演算子
    async を await すると、スレッドを止めずに同期コンテキストで同期する。

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

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

というイメージ。

  • ・・・結局、APM、EAP と同じ技術(同期コンテキスト)を使っている。

  • なお、同期コンテキストの種類によって、動きも異なる。

    • 同期コンテキストはキュー的なもので実装されている。
      例えば、前述の、

      • Windows メッセージングキュー、
      • ThreadPool
      • I/O 完了ポート
      • , etc.
    • なので、言葉尻だけで動作を想像し難く、
      利用の際は注意が必要になってくる

移行メモ(async 修飾子の正確な意味): 原文の
「async は、呼び出し元と同じ同期コンテキストで実行されることを示す」
は、結果としての挙動を述べたものだが、
async 修飾子自体にはその意味はない点を補足しておく。

async が行うのは、次の 2 つだけである。

  1. メソッド内で await を使えるようにする
  2. コンパイラにステート マシンへの書き換えを指示する

「同じ同期コンテキストに戻る」のは await 側の既定動作であり、
ConfigureAwait(false) で無効化できる(後述)。

また、async を付けてもメソッドはその場では非同期にならない
async メソッドは呼び出されたら、まず同期的に実行される
最初の「未完了の await」に到達して初めて呼び出し元に戻る。

実装方法

タスク分割方法

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.FromResultで、完了状態の 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.FromResultで、完了状態の Task<T> を生成する return を書く必要がある。
await Task.FromResult(new T());

Task.FromResult

Task.FromResult を使用すると、完了状態の Task を生成することができる。

補足(現在の書き方/最新化): 原文の
「非同期処理を呼び出さないメソッド」の書き方は、現在では改善されている。

// 原文の書き方(async を付けて、無駄に await する)
public async Task DoAsync() { await Task.FromResult(0); }

// 現在の書き方:async を付けず、完了済みタスクを返すだけ
public Task DoAsync() { return Task.CompletedTask; }
public Task<int> GetAsync() { return Task.FromResult(42); }

async を付けるとステート マシンが生成されるため、
中で await しないなら付けない方が速く、確保も減る

用途 使うもの
戻り値なしの完了済み Task.CompletedTask(.NET 4.6+)
戻り値ありの完了済み Task.FromResult(v)
例外を返す Task.FromException(ex)
キャンセル済み Task.FromCanceled(token)
同期完了が多い高頻度処理 ValueTask<T>(アロケーションを避ける)

ValueTask<T> は「ほとんど同期的に完了するが、
たまに非同期になる」処理(キャッシュ ヒットなど)で
Task の確保コストを避けるための型である。
ただし2 回 await できない等の制約があるため、
公開 API の既定は 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.WhenAll は、
待ち方によって受け取れる例外の数が変わる

var t = Task.WhenAll(t1, t2, t3);

// ① await → 最初の 1 つの例外だけが throw される
await t;

// ② 全部を見たいなら、Task から取り出す
try { await t; }
catch { foreach (var e in t.Exception.InnerExceptions) Log(e); }

awaitAggregateException を「ほどいて」最初の例外を投げる
(同期処理と同じ書き味にするための仕様)。
全件を扱いたい場合は上記 ② のように Task.Exception を見る。

また、WhenAll は一部が失敗しても、全部が終わるまで待つ
「1 つでも失敗したら即座に打ち切りたい」場合は
CancellationTokenSource と組み合わせる。

非同期メソッドの作り方

参考

詳細

ここでは、

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

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

非同期メソッドの種類

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

  • Fire & Forget
    戻り値が void のメソッド

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

Fire & Forget

非同期メソッドでも return 文を書く必要が無い。

戻り値が void なので非同期以降がフォアグラウンドに復帰しない。

  • 非同期呼び出しで投げっぱなす場合。
  • 具体的には、GUI アプリケーションのイベント・ハンドラに適用する場合。
  • 上記以外の用途での利用は意味もなく、ハマる原因になるので非推奨。

補足(async void が危険な理由): 後述のガイドラインとも重なるが、
async void単に不便なのではなく、危険である。

① 例外を捕捉できない
   → try-catch で囲んでも捕まらない
   → 同期コンテキストに直接投げられ、プロセスが落ちる
② 完了を待てない
   → テストできない。終了処理と競合する
③ 呼び出し側が「終わったか」を知る手段が一切ない

① が最も重いasync Task なら例外は Task に格納されるが、
async void ではキャッチする場所がないまま
スレッド プールに投げられ、未処理例外としてプロセスを終了させる

// 危険:例外はここでは捕まらない
try { FireAndForget(); } catch { /* 到達しない */ }
async void FireAndForget() { throw new Exception(); }

イベント ハンドラー以外では絶対に使わない
どうしても投げっぱなしにしたい場合は、
async Task にして、呼び出し側で明示的に例外を握る

_ = DoAsync().ContinueWith(t => Log(t.Exception),
        TaskContinuationOptions.OnlyOnFaulted);

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 であると思われる。

補足(ASP.NET Core では同期コンテキストが無い/最新化): 本節は
**.NET Framework 時代の ASP.NET(System.Web)**を前提としている。
ASP.NET Core では状況が大きく変わったので、要点を補う。

ASP.NET (System.Web) ASP.NET Core
同期コンテキスト AspNetSynchronizationContext あり null(存在しない)
await 後のスレッド 元のリクエスト スレッドにバインド ThreadPool の任意のスレッド
HttpContext 同期コンテキストが持ち回る IHttpContextAccessor(AsyncLocal)
.Result / .Wait() デッドロックする デッドロックはしない(が枯渇する
ConfigureAwait(false) 必須級 不要(効果がない)

ASP.NET Core で同期コンテキストが撤廃されたことにより、
本ページが詳述しているデッドロック問題の多くは
構造的に解消した。

ただし .Result / .Wait() が安全になったわけではない
デッドロックはしなくなったが、
スレッドを 1 本占有して待つため、
負荷が高まるとスレッド プール枯渇を起こす。
同期的にブロックしてはならないという結論は変わらない。

なお、同期コンテキストが残るのは主に UI
(WPF / WinForms / MAUI)であり、
そこでは本節の議論が今も有効である。

並列実行

  • 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 演算子を含むコードブロックをロックしたい場合)、

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

補足(lock の中では await が書けない): 正確には、
C# の構文として lock ブロック内に await は書けない
(コンパイル エラーになる)。

lock (_sync)
{
    await DoAsync();   // ← コンパイル エラー
}

理由は原文の言う通り、Monitorlock の実体)が
スレッド・アフィニティを持つ(取得したスレッドしか解放できない)ため。
await の前後でスレッドが変わり得る以上、成立しない。

代替は SemaphoreSlim(下記参照)。

private readonly SemaphoreSlim _lock = new(1, 1);

await _lock.WaitAsync();
try { await DoAsync(); }
finally { _lock.Release(); }

finally での Release() は必須(例外時に永久ロックになる)。

なお、.NET 9 で System.Threading.Lock 型が追加され、
lock 文がこの型に対応したが、
これも同期用であり、await は書けない点は変わらない。

WaitFor[Single|Multi]Object は例外的に使用可

Win32 の待機関数の WaitFor[Single|Multi]Object は例外的に使用可。

これらの待機関数は、

  • スレッド・アフィニティではなく、
  • 「状態変化」(ノンシグナル状態からシグナル状態への変化)を

待つ関数であるためと考える。

補足(ただし「ブロックする待機」である点は変わらない): 原文の
「WaitFor[Single|Multi]Object は例外的に使用可」は
スレッド・アフィニティの観点では正しいが、
待っている間スレッドを占有する点は同じである。

非同期文脈では、次の待たない待機を使うのが定石。

待ちたいもの 非同期版
排他制御 SemaphoreSlim.WaitAsync()
シグナル TaskCompletionSource<T>
時間 Task.Delay()Thread.Sleep は不可)
一斉開始 TaskCompletionSource + Task.WhenAll
生産者・消費者 System.Threading.Channels

TaskCompletionSource<T>
「外部のイベントを await 可能にする」ための汎用手段で、
WaitHandle を待つ場合も
ThreadPool.RegisterWaitForSingleObject と組み合わせて
Task 化するのが現在の書き方である。

その他

進捗報告

上記の仕組みで動いているとすると、進捗報告をどう実装するかが?であるが、
以下を見ると、Progress クラス、IProgress<T> インターフェースを使用するらしい事が解る。

詳しい仕組みは不明だが、GUI 上で動作しているため、
同期コンテキストの Control.Invoke、.BeginInvoke を使用しているものと思われる。

補足(Progress<T> の仕組み): 原文の推測は正しい。
Progress<T> はコンストラクタで実行時の同期コンテキストを捕捉し、
Report() されたらそのコンテキストにポストする

// UI スレッドで生成する(=ここで UI の同期コンテキストを捕まえる)
var progress = new Progress<int>(p => progressBar.Value = p);
await DoWorkAsync(progress);   // ワーカー側は Report するだけでよい

要点: Progress<T>UI スレッドで生成すること。
バックグラウンドで生成すると同期コンテキストが null になり、
コールバックが UI スレッドで動かず、例外になる

なお、ASP.NET Core など同期コンテキストが無い環境では
常に ThreadPool 上で呼ばれる点にも注意。

CancellationToken

補足(実装の指針): CancellationToken
公開する非同期メソッドの末尾引数として受け取るのが慣習である。

public async Task<T> GetAsync(string id, CancellationToken ct = default)
{
    var res = await _http.GetAsync(url, ct);   // 下流へ必ず渡す
    ct.ThrowIfCancellationRequested();          // 自前ループでは明示的に確認
    ...
}

要点:

  • 受け取ったトークンは必ず下流に流す(途中で握り潰さない)
  • キャンセルは例外OperationCanceledException)で伝わる。
    これを catch (Exception) でまとめて握るとキャンセルが効かなくなる
  • タイムアウトも同じ仕組み
    new CancellationTokenSource(TimeSpan.FromSeconds(5))
  • ASP.NET Core では HttpContext.RequestAborted
    クライアント切断時にキャンセルされるトークンとして使える

.NET 8 では CancellationTokenSource.CancelAsync() も追加された。

同期コンテキストの利用オプション

ContinueWith

補足: ContinueWithasync/await 以前の書き方であり、
既定のスケジューラが直感に反するTaskScheduler.Current を使う)等の
落とし穴があるため、新規コードでは await を使う
現在も ContinueWith が要るのは、
「投げっぱなしタスクの例外を握る」など限られた場面のみ。

ConfigureAwait

補足(現在の指針): ConfigureAwait(false)
await の後で、元の同期コンテキストに戻らなくてよい
という指示である。

場所 指針
ライブラリ ConfigureAwait(false) を付ける(呼び出し元を選ばない)
UI アプリ 付けない(UI 更新のため戻る必要がある)
ASP.NET Core 不要(同期コンテキストが無いため効果がない)
コンソール 不要

.NET 8 では ConfigureAwait(ConfigureAwaitOptions) が追加され、
SuppressThrowing(例外を投げずに待つ)等も指定できるようになった。

ガイドライン

以下の参考資料から、ガイドラインを纏めてみた。

一般的に

戻り値が void のメソッドを非同期呼び出ししない。

  • 呼び出し側でタスクの終了を検出することができない。
  • タスクで発生した例外を呼び出し側で補足することができない。
  • 例外:イベントハンドラは OK(そもそもイベントハンドラ用)。
    → 前項の理由のような挙動でも問題ないが無いため。

Task.Wait() を使う場合は注意する

  • Task.Wait() は、非同期処理を待機するため、デッドロックの原因になり易い。
  • 実行する非同期 Task の前に、Task.Wait が割り込むとデッドロックになったりする。

以下、デッドロックのサンプル。

  • UI の場合のサンプル
    同期コンテキストである Windows メッセージングキューに、
    Task.Wait、非同期 Task の順でキューイングされるため
    前者が後者の完了を待ち続け、後者が何時迄も実行されないため(と思われる)。

  • ASP.NET の場合のサンプル
    同期コンテキストである I/O 完了ポートに、
    Task.Result(≒Task.Wait)、非同期 Task の順でキューイングされるため
    前者が後者の完了を待ち続け、後者が何時迄も実行されないため(と思われる)。

    • ASP.NET で非同期 (Async) を乗りこなす – Tsmatz
      https://tsmatz.wordpress.com/2012/05/08/asp-net-mvc-async/
      • await で Post された同期処理は、GetHeader().Result(非同期処理) の処理が終わるまで待機。
      • 同時に、GetHeader().Result(非同期処理) は、この Post された同期処理を待機。
  • 参考

補足(デッドロックの原理を一枚で): このデッドロックは
同期コンテキストが「1 つしか通さない」ことに起因する

① UI スレッド:DoAsync().Result で待機(UI スレッドを占有したまま)
           ↓
② DoAsync 内の await が完了 → 「続き」を UI スレッドで実行しようとする
           ↓
③ しかし UI スレッドは ① で塞がっている
           ↓
④ ② は永久に実行されず、① も永久に待ち続ける → デッドロック

解決は 2 つしかない

  1. .Result / .Wait() を使わず、最後まで await する推奨
  2. ライブラリ側で ConfigureAwait(false) を付け、
    ② が UI スレッドに戻ろうとしないようにする(対症療法)

**「async は下から上まで(async all the way)」**という原則は、
この構造から導かれる。途中で同期に戻してはならない

ライブラリの場合

ざっくり、以下のガイドラインに従う。

  • 基本的に同期で実装する。

  • 非同期は、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 バウンドでない場合)だけ、非同期タスクを定義する意味があるが、
        この決定はライブラリ開発者ではなくアプリケーション開発者がするもの。
  • クライアントでの Task.Run

    • クライアントでも、Task.Run を使わない。
    • 理由:
      • クライアント側では Task.Run を使う理由がたくさんある。
      • しかし前述にあるように、実行コンテキストが不明なので、ライブラリ内で Task.Run を使わない。
  • 例外

    • 例外: マルチスレッドと WinJS
      WinJS は新しいバックグラウンドスレッドを作ることができないので、かわりにライブラリ側で作る必要がある。

    • 例外: Stream.ReadAsync

      • ある種のストリームはこれをサポートしない。
      • サポートされない場合は、基底クラス(Stream クラス)で Task.Run を実行するのが最も安全な方法

移行メモ(誤記): 原文の「非同期 t タスク」は
非同期タスクの誤記、「Steam クラス」は Stream クラス の誤記と
判断し修正した。

補足(「偽の非同期」というアンチパターン): この節が指摘している
Task.Run でラップしただけの非同期は、
fake async / async over sync と呼ばれる代表的なアンチパターンである。

【本物の非同期(I/O)】
   HTTP 要求を出す → スレッドを返す → 応答が来たら続きを実行
     → 待っている間、スレッドは 0 本

【Task.Run で包んだだけ】
   別のスレッドを 1 本借りて、そこでブロックして待つ
     → 消費するスレッドは減っていない。むしろ切り替えのぶん増える

サーバでは特に有害で、
「非同期にしたのにスループットが上がらない」
「むしろスレッド プールが枯渇した」の原因になる。

逆に、CPU バウンドの処理を UI スレッドから逃がす目的で
アプリケーション側が 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 の実行管理のためのコストがかかる。
    • 従って、ライブラリのユーザにはループ内で呼び出さないように注意喚起する。
  • 参考

補足(CallContext は .NET Core では使えない/最新化): CallContext
System.Runtime.Remoting.Messaging)は .NET Framework 専用で、
.NET Core 以降では利用できない。

移行先は AsyncLocal<T> である。

private static readonly AsyncLocal<string> _tenant = new();
_tenant.Value = "contoso";       // await をまたいで引き継がれる
ThreadLocal<T> AsyncLocal<T>
単位 スレッド 非同期の流れ(実行コンテキスト)
await 失われる 引き継がれる

ASP.NET Core の IHttpContextAccessor もこの仕組みで実装されている。
なお、原文の指摘通りコピーにはコストがあるため、
使いすぎない(DI で引き回せるならそちらを使う)方がよい。

メモリについての考察

非同期メソッドの呼び出しは次の 3 つのメモリ確保処理を生む。

  • ◯:ローカルの変数を保存するためのステートマシン
  • ◯:継続のためのデリゲート
  • 結果を返すためのタスク

◯が付与された、ステートマシンとデリゲートは await キーワードが
ランタイムに現れたときに作成されるため、同期処理と比べるとコストになる。

補足(現在は大幅に改善されている/最新化): この節の記述は
.NET Framework 時代の実装を前提としている。
その後、次の改善が入った。

改善 内容
ステート マシンの構造体化 同期完了する場合、ヒープ確保が起きない
ValueTask<T> 同期完了時に Task の確保を回避
IValueTaskSource Task オブジェクトの再利用(Socket 等で利用)
Async ValueTask Pooling ステート マシンのプール(.NET 5+、既定オフ)

現在は、同期的に完了するパスではアロケーションがほぼ発生しない
ところまで最適化されている。

ただし原文の結論「ループ内で大量に呼ばない」は依然有効で、
特に1 件ずつ await する N+1 パターンは避けるべきである。

// 悪い:1 件ずつ順番に待つ(N 回のラウンドトリップ)
foreach (var id in ids) results.Add(await GetAsync(id));

// 良い:まとめて並行に投げる(並列度の制御は必要)
var results = await Task.WhenAll(ids.Select(GetAsync));

// より良い:並列度を制限する(.NET 6+)
await Parallel.ForEachAsync(ids,
    new ParallelOptions { MaxDegreeOfParallelism = 8 },
    async (id, ct) => { ... });

参考

落とし穴

ガイドライン

補足(要点の要約): 長いページなので、実務上の指針を 7 点に纏める。

  1. async は下から上まで。途中で .Result / .Wait() に戻さない。
  2. async void はイベント ハンドラーだけ
  3. ライブラリでは ConfigureAwait(false)(ASP.NET Core では不要)。
  4. I/O は本物の非同期 API を使うTask.Run で包まない。
  5. CancellationToken を受け取り、下流へ流す
  6. 並列と非同期を区別する。CPU バウンドは Parallel 系。
  7. lock の中で await しないSemaphoreSlim を使う。

参考

Microsoft Learn

xin9le.net

MakCraft、徒然なブログ

非同期処理

プロデューサー/コンシューマー パターン


Tags: 移行, プログラミング, .NET開発

NetDevInfraWiki

マイクロソフト系技術情報 Wiki
Open 棟梁 Wiki

(未着手)

開発基盤部会 Wiki

移行管理: DONETODO

Clone this wiki locally