1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C# async/awaitの落とし穴と使いこなしコツ——直列実行・デッドロック・fire and forget【2026年版】

1
Posted at

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() を呼ぶと次のようになる。

  1. .Result がスレッドをブロック
  2. タスク完了後、継続が同じコンテキスト(ブロック中)に投稿される
  3. お互いが相手を待って止まる(デッドロック)

対処法

// 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 voidasync Task に変える。例外が捕捉できなくなる
  • fire and forget は明示的なラッパーを使い、内部でログを残す
  • CancellationToken は設計段階から入れておく

概要・体験談はこちら → C# async/awaitの落とし穴と使いこなしコツ

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?