ASP.NET CoreでWeb APIを作ると、ControllerやMinimal APIのアクションがほぼすべてasync/awaitになっているのに気づきます。「クライアント(JavaScript)が非同期で叩くなら十分では?」という疑問は自然です。本記事では、サーバ側でasync/awaitを使うべき理由、効果が薄い場面、避けるべきアンチパターン、実装テンプレート、移行の手順までを実務目線で徹底解説します。
ASP.NET Core Web APIにおける async/await の必要性と活用場面
質問の背景と前提
ASP.NET CoreはKestrelとスレッドプールで動く非同期I/O指向のフレームワークです。要求ごとにスレッドを「専有」するのではなく、I/O待機中はスレッドをプールへ返却し、他の要求処理に供出することで高い同時実行性を確保します。この設計を活かす鍵がasync/awaitです。
よくある疑問
- サーバ側で
asyncを使うと何が得られるのか。クライアントが非同期なら十分ではないのか。 asyncの恩恵が小さいケースはあるのか。- 「集中設定」でプロジェクト全体を自動的に非同期化できるのか。毎回
async/awaitを書く必要があるのか。
結論の要点(まずは全体像)
| ポイント | 解説 | 補足・ベストプラクティス |
|---|---|---|
I/Oバウンド処理にはasync/awaitを使う | DB・ファイル・外部HTTP等の「待つだけ」処理を非同期化すると待機中にスレッドを解放でき、有限のスレッドプールを効率利用できる。これが高負荷時のレイテンシ/スループット改善につながる。 | EF CoreのToListAsync等のAsync APIを徹底活用。AsNoTrackingを併用し読み取りを軽量化。 |
| CPUバウンドは効果が薄い | 圧縮・暗号化・画像変換など計算が支配する処理は、非同期化してもCPU使用量は下がらない。 | 重い計算はWeb要求から切り離し、キュー+バックグラウンド(例:BackgroundService/ジョブ基盤)へ。 |
| クライアントの非同期性とは独立 | ブラウザがfetchをawaitしても、サーバ側が同期ブロックするとスレッドプールが詰まり行列が伸びる。 | サーバはサーバで非同期I/O最適化を行う。UI側の実装と切り分けて考える。 |
| 高同時接続で真価 | 数百〜数千の同時要求と外部I/Oが絡むと、非同期の有無がインスタンス数や課金に直結。 | クラウド/PaaSのオートスケールやSLAを意識し、アプリ毎のボトルネック(外部API・DB)に合わせて非同期設計。 |
| 集中設定は存在しない | async/awaitはC#の言語機能。メソッド単位でTask/Task<T>/ValueTask<T>に変え、最上位(Controller/Minimal API)まで「貫通」させる。 | 途中で.Result/.Wait()に落とすのは厳禁(いわゆる“Async over Sync”)。アナライザで検出。 |
なぜサーバ側でasync/awaitが要るのか:スレッドプールの観点
ASP.NET Coreは要求を受け取ると、スレッドプールのワーカーを使って処理を始めます。同期I/Oで外部サービスを待つ間もスレッドを占有してしまうと、同時接続数が増えるほど待機スレッドだらけになり、スレッドプールが枯渇→新規要求の遅延・タイムアウトへ進みます。非同期I/Oにすると、待機中にスレッドがプールへ戻るため、限られたスレッド本数でより多くの要求を回せます。
簡易シミュレーション(直感を掴むためのモデル)
1要求あたり100msの外部I/O(DB/HTTP)を1回行うと仮定します。下は同時1000リクエスト時のイメージです。
| 実装 | I/O待機中のスレッド占有 | 必要ワーカー推定 | 症状 |
|---|---|---|---|
| 同期I/O | あり | ≒同時数(1000) | プール逼迫・待ち行列増大・スループット低下 |
| 非同期I/O | なし(待機中は解放) | 処理の臨界区間分だけ(≪1000) | 低レイテンシ・高スループットを維持 |
この差が、クライアントが非同期かどうかに関係なくサーバ側でasync/awaitを採用するべき理由です。
asyncの恩恵が小さい/不要なケース
- CPUバウンド処理:圧縮・暗号化・画像/動画変換・大規模なLINQ集計など。非同期I/Oではないので、
asyncにしてもCPUは空かない。Web要求内でやらず、ジョブに回す設計へ。 - 超軽量な処理:演算・簡単なマッピングのみで外部I/Oがゼロの場合、同期実行でも実害は出にくい。
- 基盤が同期APIしか提供しない:古いドライバなど。ただし同期APIを
Task.Runで包むのは原則NG(スレッドを専有する点は変わらず、スレッド切替コスト増)。中長期的にはドライバ刷新が本筋。
それでも「外に見える境界(Controller/Minimal API)」はasyncで統一しておくと、将来の置き換えが容易になり、タイムアウトやキャンセルの扱いも一貫します。
「集中設定で非同期化」はできる?—設計とツールで“徹底”する
async/awaitはC#の言語構文であり、ASP.NET Coreの設定スイッチではありません。したがって「プロジェクト単位で自動的に非同期化」はできません。代わりに以下で抜け漏れを抑えます。
- 戻り値をTaskに統一:サービス/リポジトリ層まで
Task/Task<T>に寄せる。可能なら非同期APIしか公開しない。 - アナライザ/ルール:同期メソッドから非同期メソッドを呼ぶ、.Result/.Wait()の使用、Asyncサフィックスなしなどを警告するルールを有効化。
- パターン化:共通の非同期ヘルパ(
WithCancellation、ReadAsAsync等)を用意し、毎回の記述を最小化。 - 「待つだけメソッド」は直返しも可:単に
return _svc.GetAsync();のようにawaitを付けずにTaskを返す(ステートマシン生成を省略)。
実装テンプレート(現場でそのまま使える最小パターン)
EF Core(読み取り)
[ApiController]
[Route("api/[controller]")]
public class DspController : ControllerBase
{
private readonly MyDbContext _context;
public DspController(MyDbContext context) => _context = context;
[HttpGet]
public async Task<ActionResult<IEnumerable<Dsp>>> GetAll(CancellationToken ct)
{
// DB呼び出しは必ずAsync APIを使い、トラッキング無効化で軽量化
var list = await _context.Dsp
.AsNoTracking()
.ToListAsync(ct);
return Ok(list);
}
}
Minimal API(外部HTTP呼び出し + タイムアウト + キャンセル)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("external")
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
.ConfigureHttpClient(c => c.Timeout = TimeSpan.FromSeconds(10));
var app = builder.Build();
app.MapGet("/proxy", async (IHttpClientFactory factory, HttpContext ctx) =>
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
cts.CancelAfter(TimeSpan.FromSeconds(8));
var client = factory.CreateClient("external");
using var res = await client.GetAsync("https://example/api", cts.Token);
res.EnsureSuccessStatusCode();
// ストリーミングで転送
ctx.Response.ContentType = res.Content.Headers.ContentType?.ToString() ?? "application/json";
await using var s = await res.Content.ReadAsStreamAsync(cts.Token);
await s.CopyToAsync(ctx.Response.Body, cts.Token);
});
app.Run();
大きなレスポンスのストリーミング(IAsyncEnumerable<T>)
大量データは一括ロードせず、逐次で返すとメモリ効率が向上します。C# 8以降のIAsyncEnumerable<T>とawait foreachで表現できます。
[HttpGet("stream")]
public async Task Stream(CancellationToken ct)
{
Response.ContentType = "application/json; charset=utf-8";
await Response.StartAsync(ct);
await Response.WriteAsync("[", ct);
bool first = true;
await foreach (var item in _repo.StreamItemsAsync(ct))
{
if (!first) await Response.WriteAsync(",", ct);
first = false;
await Response.WriteAsJsonAsync(item, ct);
await Response.Body.FlushAsync(ct); // back-pressureに合わせて適宜flush
}
await Response.WriteAsync("]", ct);
}
ファイルI/O(await usingとIAsyncDisposable)
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file, CancellationToken ct)
{
var path = Path.Combine("uploads", Path.GetRandomFileName());
await using var fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true);
await file.CopyToAsync(fs, ct);
return Accepted(new { path });
}
避けるべきアンチパターンと落とし穴
.Result/.Wait()の使用:非同期を同期化するとスレッドを塞ぎ、デッドロックやスループット低下の温床に。- 同期I/Oを有効化する:既定で同期I/Oは無効です。互換目的で有効化するのは最後の手段。根本対策は非同期APIへの置き換え。
Task.Run乱用:I/OをTask.Runに包むのは無意味。CPUバウンドを分離したいときだけ検討(それでもバックグラウンドへ)。ConfigureAwait(false)の思考停止:ASP.NET Coreは既定で同期コンテキストを持たないため、通常は不要。むやみに付けると読みづらいだけ。- キャンセル未対応:
HttpContext.RequestAbortedを渡し、下層のAPIまで伝播させる。EF CoreやHttpClientはCancellationToken対応。 - 例外/タイムアウトの一律化:外部APIやDBでタイムアウトが発生したら適切なステータス(504/408/503等)へ正規化し、リトライ・サーキットブレーカを設計。
性能チューニングの勘所(非同期を前提に最大化)
- シリアライザ:
System.Text.Jsonの非同期API(SerializeAsync/DeserializeAsync)を使い、バッファを最小に。 - DBアクセス:読み取りは
AsNoTracking、ページングはSkip/Take、投機的プリフェッチは避ける。トランザクションはスコープを最短に。 - 外部HTTP:
IHttpClientFactoryでソケット枯渇を防ぎ、ポリシー(再試行/タイムアウト/バルクヘッド)を付与。 - スレッドプール観測:処理遅延が出たらまずスレッドプールの空き、GC、ロック競合を観る。非同期化の欠落がないか棚卸し。
「いつasyncにするか」を即決できる判断表
| シナリオ | 推奨 | 理由/注意 |
|---|---|---|
| EF CoreでDBを読む/書く | 必ず非同期(ToListAsync/SaveChangesAsync) | I/O待機が主体。スレッドの解放効果が大。 |
| 外部RESTを呼ぶ | 必ず非同期(HttpClientのAsync) | ネットワーク待機が支配。キャンセル/タイムアウトも扱いやすい。 |
| 大きなJSON/CSVを返す | 非同期 + ストリーミング | メモリと初動レイテンシを抑制。IAsyncEnumerableやBodyWriterを活用。 |
| 画像変換・Zip圧縮 | 同期 or バックグラウンド | CPUバウンド。Web要求内で長時間走らせない。 |
| 既存ライブラリが同期APIのみ | 短期:同期のまま。中長期:置換 | Task.Runで包むのは応急処置にもなりにくい。 |
段階的移行ガイド(既存同期コードを安全に非同期化)
- 末端から着手:データアクセス・外部APIクライアントなど「I/Oの発生源」をAsync APIへ置換。
- シグネチャを上へ伝播:呼び出し元を
Task/Task<T>に変更し、Controller/Minimal APIまで貫通させる。 - 非同期ギャップを排除:
.Result/.Wait()を全廃。代替実装がない箇所は設計ごと見直す。 - キャンセルを配線:
CancellationTokenをメソッド引数に追加し、HttpContext.RequestAbortedを伝播。 - ロードテスト:同時接続時のレイテンシ/スループット/CPU/メモリ/例外を収集し、回帰がないか確認。
FAQ(よくある誤解と実務のコツ)
非同期にすればいつでも速くなる?
いいえ。非同期はスケーラビリティを高める技術で、単発リクエストの純粋な実行時間が短くなるとは限りません。価値が最大になるのは同時接続が高い場面です。
ValueTaskは使うべき?
アプリ層では原則Taskで十分です。ValueTaskは高頻度・低レイテンシのライブラリ内で割り当てを削る目的で使う高度な道具で、誤用すると逆に遅くなります。
例外はどう流れる?
asyncメソッド内で投げた例外はawait時に再スローされます。グローバルにUseExceptionHandlerやフィルタで正規化し、スタックトレースを温存した上でエラーレスポンスを組み立てます。
ConfigureAwait(false)は付けるべき?
ASP.NET Coreでは通常不要です(UIスレッドの同期コンテキストがない)。一律付与は可読性低下に繋がるため、方針を決めてプロジェクト全体で統一してください。
待機を擬似的に入れたいときは?
ポーリング間隔などにThread.Sleepは使わずTask.Delayを用います。Sleepはスレッドを占有、Delayは非占有です。
Metricsの観点で何を見る?
平均/95/99パーセンタイルのレイテンシ、同時要求数、ThreadPoolの待機数、失敗率、タイムアウト件数、GCポーズ時間など。非同期化が進むほどCPU以外のボトルネックが浮き彫りになります。
実務で役立つコード断片集
キャンセルの徹底伝播
public Task<IEnumerable<Item>> GetAsync(CancellationToken ct)
=> _db.Items.AsNoTracking().ToListAsync(ct);
[HttpGet("{id}")]
public Task GetById(int id, CancellationToken ct)
=> _db.Items.FindAsync(new object[] { id }, ct).AsTask();
外部APIのレジリエンス(簡略版)
public class ExternalClient(HttpClient http)
{
public async Task<Weather> GetWeatherAsync(string city, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"/w/{city}");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
res.EnsureSuccessStatusCode();
await using var s = await res.Content.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync<Weather>(s, cancellationToken: ct)
?? throw new InvalidOperationException("invalid response");
}
}
CPUバウンドの分離(バックグラウンドキュー)
public interface IJobQueue { ValueTask EnqueueAsync(WorkItem item, CancellationToken ct); }
public class JobQueue : BackgroundService, IJobQueue
{
private readonly Channel _ch = Channel.CreateBounded(1000);
public ValueTask EnqueueAsync(WorkItem item, CancellationToken ct) => _ch.Writer.WriteAsync(item, ct);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var item in _ch.Reader.ReadAllAsync(stoppingToken))
{
// 重い処理はここで
await ProcessAsync(item, stoppingToken);
}
}
}
チェックリスト(デプロイ前に必ず確認)
- Controller/Minimal APIからデータアクセス層まで全てが非同期APIで貫通している。
.Result/.Wait()/GetAwaiter().GetResult()が存在しない。- 外部HTTP/DB/ファイルのキャンセルが
HttpContext.RequestAbortedに繋がっている。 - 大きな応答はストリーミング、巨大リストの一括シリアライズを避ける。
- 外部APIにタイムアウト/リトライ/サーキットブレーカを設定している。
- ロードテストで同時接続時のP95/P99レイテンシが許容内。
実測イメージ(モデルケースの比較)
下表は、平均100msの外部I/Oを1回含むAPIを同時500/1000接続で叩いた場合の概念的な比較です(環境により大きく変動します)。
| 同時接続 | 実装 | P95レイテンシ | 失敗率 | CPU使用率 |
|---|---|---|---|---|
| 500 | 同期I/O | 800–1500ms | 2–5% | 低〜中(待機で埋まる) |
| 500 | 非同期I/O | 140–250ms | <1% | 中(スレッド有効活用) |
| 1000 | 同期I/O | 2000ms以上 | 10%超 | 低(ほぼ待機) |
| 1000 | 非同期I/O | 250–400ms | 1–2% | 中〜高 |
非同期の価値は、スレッドを「待機で浪費しない」ことに尽きます。
サンプル:Controllerからリポジトリまで“全面非同期”
// Controller
[HttpGet("{id:int}")]
public Task<ActionResult<OrderDto>> GetOrder(int id, CancellationToken ct)
=> _service.GetOrderAsync(id, ct);
// Service
public class OrderService
{
private readonly IOrderRepository _repo;
public OrderService(IOrderRepository repo) => _repo = repo;
public async Task<ActionResult<OrderDto>> GetOrderAsync(int id, CancellationToken ct)
{
var order = await _repo.FindAsync(id, ct);
if (order is null) return new NotFoundResult();
return new OkObjectResult(Map(order));
}
}
// Repository
public interface IOrderRepository
{
Task FindAsync(int id, CancellationToken ct);
}
public class EfOrderRepository(MyDbContext db) : IOrderRepository
{
public Task FindAsync(int id, CancellationToken ct)
=> db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, ct);
}
補足:命名、アナライザ、テストの実務Tips
- 命名:非同期メソッド名は
Asyncサフィックス(GetAsync)。統一で可読性UP。 - アナライザ:同期メソッドからAsyncを呼ぶ、同期化メソッドの使用などを検出するルールを有効化し、CIで逸脱を弾く。
- ユニットテスト:テストも
async Taskで書き、Taskの完了をawaitする。.Result禁止。 - タイムアウト:
HttpClient.TimeoutとCancellationTokenSource.CancelAfterを併用し、ハングを防止。
まとめ:実務で“効く”非同期の原則
- I/O待機を伴う処理は全面
async/await:Controllerからデータアクセスまで一貫して非同期化し、スレッドを待機で塞がない。 - CPUバウンドは分離:Web要求内で長時間走らせず、ジョブ/キューへ。
- 集中設定はない:コードで書く。アナライザとテンプレで抜け漏れを防ぐ。
- キャンセルとタイムアウトを徹底:
HttpContext.RequestAbortedの配線と、各I/Oの適切な待ち時間を設計。 - ストリーミングでメモリを守る:
IAsyncEnumerableやCopyToAsyncで段階的に送受信。
非同期は魔法ではありませんが、Web APIにおける「高同時接続でも崩れない」品質の土台です。まずはI/Oの非同期化と“同期化の撲滅”から着手し、段階的にストリーミングやバックグラウンド分離へ広げていきましょう。

コメント