2026/05/13

C# async/awaitの落とし穴と使いこなしコツ

C# async/awaitの落とし穴と使いこなしコツ——「待たない」設計で変わること

async/awaitはC# 5から使えるようになって久しいけど、「とりあえず付ければ非同期になる」という理解のまま書くと、同期コードより遅くなったり、デッドロックで詰んだりする。自分もそういうハマり方を何度かやった。

この記事では、async/awaitで実際に踏みやすい罠と、「待たない」設計のコツを整理する。.NET 10(C# 14)を前提にしているが、考え方自体は.NET 6以降であれば共通だ。

罠その1——awaitを直列に並べると順次実行になる

いちばんよくあるミスはこれだ。複数の非同期処理を「なんとなく」awaitで並べると、前の処理が完全に終わるまで次が始まらない。

// ダメな例: 合計300ms かかる
var user = await GetUserAsync(userId);       // 100ms
var orders = await GetOrdersAsync(userId);   // 100ms
var address = await GetAddressAsync(userId); // 100ms

3つのAPIコールに依存関係がないのに、直列に待つだけで300msかかる。Task.WhenAllを使えば並列実行できて、合計は最大の100msに近づく。

// 正しい例: 約100ms で終わる
var userTask    = GetUserAsync(userId);
var ordersTask  = GetOrdersAsync(userId);
var addressTask = GetAddressAsync(userId);

await Task.WhenAll(userTask, ordersTask, addressTask);

var user    = userTask.Result;    // WhenAll後なので完了済み
var orders  = ordersTask.Result;
var address = addressTask.Result;

Task.WhenAllのあとで.Resultを使っているのは、この時点では既にタスクが完了しているから安全だ(デッドロックの心配はない)。もちろん各タスクをawaitしてもよい。

例外が複数ある場合の注意

Task.WhenAllはいずれかのタスクが例外を投げるとAggregateExceptionとして集約する。awaitするとその中の最初の例外がアンラップされて投げられる。複数タスクすべての例外を取り出したい場合は、task.Exceptionを個別に確認する必要がある。

罠その2——.Result/.Waitで同期的に待つとデッドロックする

ASP.NET Core以前の旧来のASP.NETや、WPF/WinFormsのUIスレッドで.Result.Wait()を呼ぶと、デッドロックが起きることがある。

// UIスレッドや旧ASP.NETコンテキストでは危険
var result = GetDataAsync().Result;   // デッドロックの可能性
var result = GetDataAsync().Wait();   // 同上

仕組みを簡単に言うと、awaitは元のSynchronizationContextに戻ろうとするが、.Resultでそのスレッドがブロックされているため、お互いが待ち合って詰む。

ASP.NET Core(.NET 5以降)はSynchronizationContextがないためこの問題は発生しない。ただし、ライブラリコードとして書く場合や古い環境をサポートする場合は要注意だ。回避策は2つある。

// 方法1: ConfigureAwait(false) でコンテキストへの復帰を抑制
var data = await GetDataAsync().ConfigureAwait(false);

// 方法2: 非同期メソッドチェーンを維持してawaitで待つ(本質的な解決)
public async Task ProcessAsync()
{
    return await GetDataAsync();
}

ライブラリを書く場合はConfigureAwait(false)を付けるのがベストプラクティスとされている。呼び出し元のコンテキストに縛られないため、デッドロックのリスクを下げられる。

罠その3——async voidで例外が消える

async voidはイベントハンドラ以外では使うべきじゃない。メソッドが例外を投げてもキャッチする手段がなく、プロセスがクラッシュするか、例外が無視される。

// ダメな例
async void LoadDataAsync()
{
    var data = await FetchAsync();  // 例外が飛んでも呼び元でキャッチできない
    Process(data);
}

// 正しい例: async Task にする
async Task LoadDataAsync()
{
    var data = await FetchAsync();
    Process(data);
}

// イベントハンドラだけはasync voidが許容される
private async void Button_Click(object sender, EventArgs e)
{
    await LoadDataAsync();  // ここでキャッチできる形にする
}

async voidを書きたくなったら、async Taskに変えて、呼び出し元でawaitするように設計を直すのが先だ。

fire and forget——意図的に「待たない」パターン

ログ送信や通知など、「結果は要らないし失敗してもいい」処理を意図的に待たないことがある。async voidに似ているが、明示的に非同期タスクを手放す書き方を使う。

// awaitしないとコンパイラ警告が出る場合は _ で受ける
_ = SendTelemetryAsync(eventData);

// または Task.Run でスレッドプールに委ねる
_ = Task.Run(() => SendTelemetryAsync(eventData));

fire and forgetは例外が握りつぶされるリスクがある。本番コードで使う場合は、タスク内でtry-catchして例外をログに残す処理を入れておくことが多い。

private static async Task FireAndForgetAsync(Func action)
{
    try
    {
        await action();
    }
    catch (Exception ex)
    {
        // ログに残して握りつぶす
        logger.LogError(ex, "Fire-and-forget task failed");
    }
}

// 使い方
_ = FireAndForgetAsync(() => SendTelemetryAsync(eventData));

CancellationTokenを渡す

非同期メソッドを書くならCancellationTokenを受け取れる設計にしておく。HTTPリクエストのキャンセル・タイムアウト・アプリシャットダウン時のクリーンアップが、これがないと一切効かない。

// キャンセルトークンを受け取るシグネチャ
public async Task GetOrderAsync(Guid id, CancellationToken cancellationToken = default)
{
    return await _repository.FindAsync(id, cancellationToken);
}

// ASP.NET Coreではコントローラから自動で渡される
[HttpGet("{id}")]
public async Task Get(Guid id, CancellationToken cancellationToken)
{
    var order = await _service.GetOrderAsync(id, cancellationToken);
    return Ok(order);
}

引数の末尾にCancellationToken cancellationToken = defaultとデフォルト値付きで追加しておくと、既存の呼び出しコードを壊さずに後から対応できる。

まとめ

  • 依存関係のない複数の非同期処理はTask.WhenAllで並列化する。直列awaitは遅い
  • .Result/.Wait()による同期ブロックはデッドロックの原因になる。ライブラリコードではConfigureAwait(false)を付ける
  • async voidはイベントハンドラ以外では使わない。例外が捕捉できなくなる
  • fire and forgetは_ =で明示し、内部でtry-catchしてログを残す
  • 非同期メソッドにはCancellationTokenを通しておく

C#のプロパティ宣言の最新事情(fieldキーワード・initrequired)についてはこちら。→ C# 2026年のプロパティ宣言まとめ