TL;DR
-
awaitを直列に並べるだけでは並列実行されない。Task.WhenAllで並列化する -
.Result/.Wait()による同期ブロックはデッドロックの原因。ライブラリコードではConfigureAwait(false)を使う -
async voidはイベントハンドラ以外では使わない。例外が捕捉できなくなる - fire and forget は
_ =で明示し、内部で try-catch してログを残す - 非同期メソッドには
CancellationTokenを通しておく
環境
| 項目 | バージョン |
|---|---|
| .NET | .NET 10 |
| C# | 14 |
1. await 直列列挙は順次実行——Task.WhenAll で並列化する
問題のあるコード
// 合計約 300ms かかる(依存関係なし)
var user = await GetUserAsync(userId); // 100ms
var orders = await GetOrdersAsync(userId); // 100ms
var address = await GetAddressAsync(userId); // 100ms
各 await は前のタスクが完了するまで次を開始しない。3つのAPIに依存関係がなければ、これは単純な待ちすぎだ。
並列化後
// 約 100ms で終わる(最も遅いタスクに律速)
var userTask = GetUserAsync(userId);
var ordersTask = GetOrdersAsync(userId);
var addressTask = GetAddressAsync(userId);
await Task.WhenAll(userTask, ordersTask, addressTask);
var user = userTask.Result; // WhenAll 後は完了済みなので .Result は安全
var orders = ordersTask.Result;
var address = addressTask.Result;
Task.WhenAll の後で .Result を使うのは、この時点で全タスクが完了していることが保証されているため安全。await で取り出してもよい。
例外の扱い
複数タスクがどちらも例外を投げた場合、await Task.WhenAll(...) は最初の例外だけをアンラップして投げる。全タスクの例外を確認したい場合は各タスクの Exception プロパティを直接参照する。
var tasks = new[] { task1, task2, task3 };
try
{
await Task.WhenAll(tasks);
}
catch
{
var exceptions = tasks
.Where(t => t.IsFaulted)
.Select(t => t.Exception!.InnerException)
.ToList();
// exceptions に全タスクの例外が入る
}
2. .Result/.Wait() によるデッドロック
なぜデッドロックになるか
await はデフォルトで現在の SynchronizationContext をキャプチャし、継続をそのコンテキスト上に投稿しようとする。SynchronizationContext が1スレッドしか許可しないコンテキスト(旧ASP.NET・WPF/WinFormsのUIスレッド)で .Result/.Wait() を呼ぶと次のようになる。
-
.Resultがスレッドをブロック - タスク完了後、継続が同じコンテキスト(ブロック中)に投稿される
- お互いが相手を待って止まる(デッドロック)
対処法
// NG: 旧ASP.NET・UIスレッドでは危険
var result = GetDataAsync().Result;
// 方法1: ライブラリコードでは ConfigureAwait(false) を付ける
public async Task<string> GetDataAsync()
{
var raw = await _httpClient.GetStringAsync(url).ConfigureAwait(false);
return Process(raw);
}
// 方法2: async チェーンを維持してトップまで await する(根本的な解決)
public async Task<IActionResult> GetAction()
{
var result = await GetDataAsync();
return Ok(result);
}
ASP.NET Core では不要
ASP.NET Core(.NET 5以降)は SynchronizationContext を持たないため、このデッドロックは発生しない。ConfigureAwait(false) は ASP.NET Core アプリケーションコードでは省略してよい。ただし複数の実行環境で動くライブラリコードでは引き続き付けることが推奨されている。
3. async void は使わない
async void メソッドで例外が投げられると、呼び出し元でキャッチできない。SynchronizationContext が存在する場合はその上で例外が再スローされ、多くの場合プロセスクラッシュになる。
// NG: 例外が捕捉できない
async void LoadDataAsync()
{
var data = await FetchAsync(); // ここで例外が起きても呼び元でキャッチ不可
Process(data);
}
// OK: async Task にする
async Task LoadDataAsync()
{
var data = await FetchAsync();
Process(data);
}
// イベントハンドラのみ async void が許容される
private async void Button_Click(object sender, EventArgs e)
{
try
{
await LoadDataAsync(); // 内部を async Task にしてここで await
}
catch (Exception ex)
{
// ここでキャッチ
}
}
4. fire and forget——意図的に待たないパターン
ログ送信・通知など、結果不要・失敗許容の処理を意図的にバックグラウンドで実行したい場合のパターン。
基本パターン
// discard で「意図的に待たない」を明示(コンパイラ警告を抑制)
_ = SendTelemetryAsync(eventData);
例外を握りつぶさないラッパー
private async Task SafeFireAndForgetAsync(Func<Task> action, ILogger logger)
{
try
{
await action();
}
catch (Exception ex)
{
logger.LogError(ex, "Fire-and-forget task failed");
}
}
// 使い方
_ = SafeFireAndForgetAsync(() => SendTelemetryAsync(eventData), _logger);
async void との違いは、例外を内部で処理できる点。
5. CancellationToken を通す
非同期メソッドを書くときは最初から CancellationToken を受け取れる設計にしておく。HTTPリクエストキャンセル・タイムアウト・グレースフルシャットダウン時のクリーンアップが機能しなくなる。
// ライブラリ・サービス層
public async Task<Order> GetOrderAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _repository.FindAsync(id, cancellationToken);
}
// ASP.NET Core コントローラ(フレームワークが自動でトークンを渡す)
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken)
{
var order = await _service.GetOrderAsync(id, cancellationToken);
return Ok(order);
}
cancellationToken = default をデフォルト値にしておくと、トークンを渡さない既存の呼び出しを壊さずに後から対応できる。
使い分けまとめ
| 場面 | 推奨パターン |
|---|---|
| 独立した複数の非同期処理 |
Task.WhenAll で並列化 |
| 同期コードから Task の結果が必要 | async チェーンを維持する。やむを得ない場合は ConfigureAwait(false)
|
| 結果不要・失敗許容の処理 |
_ = SafeFireAndForgetAsync(...) でラップ |
| イベントハンドラ |
async void のみ許容。内部は async Task にして await
|
| タイムアウト・キャンセル対応 | 全非同期メソッドに CancellationToken を通す |
まとめ
-
awaitの直列列挙は順次実行。並列化はTask.WhenAllを使う -
.Result/.Wait()のデッドロックは旧環境・ライブラリコードで注意。ASP.NET Core では発生しない -
async voidはasync Taskに変える。例外が捕捉できなくなる - fire and forget は明示的なラッパーを使い、内部でログを残す
-
CancellationTokenは設計段階から入れておく
概要・体験談はこちら → C# async/awaitの落とし穴と使いこなしコツ