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キーワード・init・required)についてはこちら。→ C# 2026年のプロパティ宣言まとめ