Menu

C#のasync/await:Task、Task<T>、WhenAll、よくある落とし穴

asyncとawaitを使うと、C#のコードはスレッドをブロックせずに遅い処理を待てます。asyncメソッドの実行のされ方、TaskとTask<T>、Task.WhenAllで独立した処理を同時に実行する方法、非同期コードの例外、そしてasync voidと.Resultがバグやデッドロックを起こす理由を学びます。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

プログラムの遅い処理のほとんどは待ち時間です。Webサーバーが応答するのを、データベースが行を返すのを、ファイルが読まれるのを待っています。async と await を使うと、メソッドはスレッドを占有せずにそうした待ち時間の間は一時停止し、中断したところから続けられます。コードは普通のコードと同じように上から下へ読めます。

最初の非同期メソッド

async メソッドは Task(結果なし)か Task<T>(型 T の結果)を返します。その中で、await は別のタスクを待ち、その結果を返します。

出力:

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync は return 2.50m と書き、コンパイラがそれを、メソッドが返す Task<decimal> で包みます。await がそれを再び取り出します。Task.Delay は Thread.Sleep の非同期版で、その間スレッドをブロックせずに、時間が経過した後に完了します。

C# 7.1以降は Main 自体を非同期にでき、現代の.NETではこのプログラムはそのように書きます。

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

このページの実行できる例では、通常の Main から .GetAwaiter().GetResult() で非同期の RunAsync メソッドを呼んでいます。これはコンパイラが非同期の Main に対して生成するものです。コンソールアプリでは安全です。同じ呼び出しがUIのコードでは危険な理由は、デッドロックの節で説明します。

awaitで何が起きるか

非同期メソッドは、終わっていないタスクに対する最初の await までは同期的に実行されます。その時点で呼び出し元に Task を返し、メソッドの残りの部分は、待っているタスクが完了したときに実行される継続になります。

出力:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

最初の await までのすべては呼び出し元のスレッドで実行されるので、DownloadAsync が戻る前に「Download: starting」が表示されます。その後メソッドは終わっていないタスクを返し、呼び出し側は処理を続け、「Download: finished」は遅延が終わったときに初めて表示されます。非同期メソッドを呼ぶと処理が始まり、それをawaitすることで結果を待ちます。

async はスレッドを作りません。メソッドが一時停止している間、それを待っているスレッドはまったくありません。だから、サーバーはほんの数個のスレッドで、データベースを待つ何千ものリクエストを抱えられます。別のスレッドで実行すべきCPU負荷の高い処理には、タスクで扱う Task.Run を使います。

Task.WhenAllで処理を同時に実行する

呼び出しを1つずつawaitするのは逐次的です。それぞれが前のものが終わってから始まります。処理が互いに依存していないなら、すべてを開始してから Task.WhenAll でまとめてawaitします。

出力例:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

逐次版は3回の待ち時間の合計がかかり、並行版は最も遅いものとほぼ同じ時間で済みます。Task.WhenAll は、どれが先に終わったかに関係なく、渡したタスクと同じ順序で結果を返します。リストにも使えるので、「すべての項目を取得する」ときはこの形が一般的です:await Task.WhenAll(ids.Select(id => FetchAsync(id)))。

Task.WhenAny は対になるもので、最初のタスクが完了した時点で完了します。タイムアウトや「最初の答えを採用する」場合に便利です。

非同期コードの例外

非同期メソッドの中で投げられた例外は返されたタスクに保存され、タスクがawaitされたときに再スローされるので、await を囲む普通の try/catch が使えます。

出力:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

LoadProfileAsync(-1) を呼んでも例外は投げられませんでした。例外は、何かがそれをawaitするまでタスクの中にあります。誰もawaitしないタスクは例外を完全に隠してしまうので、開始したものは必ずawaitすべき理由がもう1つ増えます。WhenAll では await は最初の例外を再スローし、結合されたタスクの Exception プロパティ(AggregateException)はすべての例外を保持します。ok はすでに完了しているので、そこで ok.Result を読むのは問題ありません。

async void:イベントハンドラー専用

非同期メソッドは void を返すこともできます。イベントハンドラー以外では避けます。

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

async void ではawaitするタスクがないので、呼び出し側は処理が終わる前に先へ進み、中で投げられた例外は同期コンテキスト(またはスレッドプール)で直接発生して、呼び出し側の try/catch は届きません。コンソールアプリやサーバーアプリでは、たいていプロセスが終了します。async voidのイベントハンドラーの中では、すべて自分でキャッチします。

関連する間違いに、非同期メソッドを await なしで呼ぶことがあります。コンパイラは警告(CS4014)を出し、メソッドはバックグラウンドで実行され、その結果もエラーも誰も確認しません。

.Resultと.Wait()によるデッドロック

.Result や .Wait() でタスクをブロックして待つのは、非同期コードが最もよく失敗する場面です。同期コンテキストのあるアプリケーション(WinForms、WPF、MAUI、従来のASP.NET)では、次の順序で起きます。

  1. UIスレッドが GetDataAsync().Result を呼び、タスクを待ってブロックします。
  2. GetDataAsync の中で await が終わります。既定では、その継続は開始したコンテキスト、つまりUIスレッドで実行されなければなりません。
  3. UIスレッドは手順1でブロックされているので継続は実行されず、タスクは完了せず、手順1も終わりません。

アプリは例外もなく固まります。コンソールアプリとASP.NET Coreにはそのようなコンテキストがないので、同じコードがテストのプログラムでは動き、デスクトップアプリでは固まります。直し方:

  • ブロックする代わりに、呼び出しの連鎖の上までずっと await を使います(「async all the way」)。そのためにイベントハンドラーは async void にできます。
  • 呼び出し元のコンテキストに戻る必要のないライブラリのコードでは、await SomethingAsync().ConfigureAwait(false); と書きます。すると継続はキャプチャしたコンテキストではなくスレッドプールで実行され、ライブラリは不要なスレッドの切り替えを省けます。ただし、ブロックする呼び出し元を守れるのは、連鎖のすべての await が同じようにしている場合だけなので、.Result の直し方ではなく、ライブラリの良い習慣として扱います。

ASP.NET Coreのアプリケーションのコードでは、戻るべきコンテキストがないので ConfigureAwait(false) は不要です。

よくある間違い

  • 独立した呼び出しを1つずつawaitする。 開始してから await Task.WhenAll(...) を使います。
  • async void メソッド。 Task を返します。async void はイベントハンドラーのために残しておきます。
  • UIや従来のASP.NETのコードでの .Result と .Wait()。 デッドロックします。代わりにawaitします。
  • await を忘れる。 処理は誰にも確認されずに実行され、その例外は消えます。
  • I/Oを Task.Run で包む。 File.ReadAllTextAsync や HttpClient.GetStringAsync のような非同期APIはすでにスレッドを解放しています。Task.Run はスレッドプールへの移動を1回増やすだけです。
  • async が「別のスレッドで実行される」ことだと思う。 「ブロックせずに一時停止できる」という意味です。CPUバウンドの処理には Task.Run を使います。

よくある質問

C#のasyncとawaitはどう動きますか?

メソッドに async を付けると await を使えるようになります。メソッドが終わっていないタスクの await に達すると、残りの処理を表す Task を渡して、すぐに呼び出し元に戻ります。待っている処理が完了すると、メソッドは await の後から再開します。待っている間、ブロックされているスレッドはありません。

TaskとTask<T>の違いは何ですか?

Task は値を生成しない処理を表し、void メソッドの非同期版です。Task<T> は T を生成する処理を表し、Task<int> を await すると int が得られます。async Task<int> と宣言した非同期メソッドでは単に return 42; と書き、コンパイラがそれをタスクで包みます。

複数の非同期処理を同時に実行するには?

先にすべて開始してから、まとめてawaitします:var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);。await GetUserAsync(); await GetOrdersAsync(); と書くと順に実行されるので、合計時間は最も長いものではなく、すべての合計になります。

C#のasync voidが良くないのはなぜですか?

async void メソッドはawaitできないので、呼び出し側はいつ終わったのかがわかりません。また、中で投げられた例外を呼び出し側がキャッチできず、同期コンテキストで発生して、たいていプロセスをクラッシュさせます。代わりに Task を返します。async void は、シグネチャが void を要求するイベントハンドラーのためだけに存在します。

.Resultや.Wait()でデッドロックが起きるのはなぜですか?

同期コンテキストのあるアプリ(WinForms、WPF、従来のASP.NET)では、.Result がUIスレッドやリクエストのスレッドをブロックし、一方でawaitしたメソッドの継続は同じスレッドで実行されるのを待ちます。互いに相手を永遠に待ち続けます。ブロックする代わりに呼び出し元まですべて await を使い、ライブラリのコードでは ConfigureAwait(false) を使います。

C#のMainをasyncにできますか?

できます。C# 7.1以降、static async Task Main() や static async Task<int> Main() と書けます。トップレベルステートメント(C# 9)では await を直接使えます。古いコードでの同等の方法は、通常の Main から MainAsync().GetAwaiter().GetResult() を呼ぶことで、コンソールアプリには同期コンテキストがないので、そこでは安全です。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める