「キャッシュ済み結果を返す/同期的に完了する」パスを含む async メソッドは、規模が大きくなるほど見逃せないオーバーヘッドを生みます。特にメモリキャッシュや入力検証で高頻度に“即時完了”する処理は、ヒープ確保とスレッド切替のコストがボトルネック化します。本記事では、その正体・なぜ問題か・どう直すかを、具体的なC#コードと実装パターンで深掘りします。
なぜ「キャッシュ済み/同期完了」の async が問題になるのか
async/await は I/O 待ちを効率化するための仕組みです。ところが次のように、実際には非同期 I/O を伴わないパスが高頻度で現れると、毎回の呼び出しで「不要なオブジェクト確保」や「スレッド切替」を招きます。
- メモリキャッシュ命中(Dictionary/ConcurrentDictionary/IMemoryCache など)
- 入力検証やガード節(引数チェック、事前条件で即座に結果がわかる)
- フラグ判定(「既に作成済み」「未変更」など)
- 短い CPU 計算のみで終わるケース(I/O 無し)
典型例(問題のあるパターン)
public async Task<UserDto?> GetUserAsync(int id)
{
if (_memoryCache.TryGetValue(id, out var cached))
return cached; // ここは同期で結果が確定
// 以降はDBなどのI/O
var entity = await _db.Users.FindAsync(id);
return entity is null ? null : Map(entity);
}
この実装は、キャッシュ命中時にも Task オブジェクトの割り当て が発生します。tight loop(高頻度呼び出し)や高負荷サーバーでは GC 負荷が雪だるま式に増え、スループットとレイテンシに悪影響を与えます。
用語の整理:「キャッシュ済み」「同期的に完了する」とは
| 用語 | 具体例 | 特徴 |
|---|---|---|
| キャッシュ済み結果を返す | プロセス内 Dictionary/IMemoryCache 命中、LRU キャッシュ命中 | I/O なし。参照/値の取り出しだけで即座に返せる |
| 同期的に完了する | 引数検証 NG で即エラー、フラグ判定で「処理不要」確定 | await 不要。スレッド切替も不要 |
| 非同期 I/O | DB/外部API/ファイル/ネットワークアクセス | await による待機でスレッドの有効活用が本来の目的 |
根本原因:ヒープ確保と状態機械のコスト
- Task は参照型:
Task/Task<T>はヒープ上に割り当てられます。同期完了でも多くの場合は割り当てが発生します。 - 状態機械(ステートマシン):
asyncはステートマシンを生成します。同期完了であっても、Task を返す以上 そのラッパー(完了済み Task)のコストを払います。 - コンテキスト捕捉:UI/古いASP.NETでは同期コンテキスト捕捉が余計な切替を生み得ます(ASP.NET Core は原則なし)。
Task と Task<T> の違い(コスト観点)
| 型 | 用途 | 割り当て | 補足 |
|---|---|---|---|
| Task(非ジェネリック) | 戻り値なし | 通常割り当て。Task.CompletedTask は再利用可 | 同期完了なら Task.CompletedTask を返すと追加割り当て回避 |
| Task<T> | 戻り値あり | 通常割り当て。Task.FromResult(T) は軽量だが割り当ては発生 | ジェネリックか否かで根本コスト差は小。値型である点が重要ではない |
| ValueTask/ValueTask<T> | 戻り値あり/なし | 同期完了なら割り当て回避。非同期時は内部で Task 等を包む | 制約あり(後述)。高頻度同期完了パスに向く |
即効性のある改善:ValueTask か、Task の完成済みインスタンスを返す
パターンA:実際に同期完了が多いなら ValueTask<T>
public ValueTask<UserDto?> GetUserAsync(int id)
{
if (_memoryCache.TryGetValue(id, out var cached))
return new ValueTask<UserDto?>(cached); // 割り当てゼロ
return new ValueTask<UserDto?>(FetchFromDbAsync(id)); // 非同期時のみ Task 生成
}
private async Task FetchFromDbAsync(int id)
{
var entity = await _db.Users.FindAsync(id).ConfigureAwait(false);
return entity is null ? null : Map(entity);
}
- キャッシュ命中(同期完了)では ヒープ割り当てゼロ。
- 外部 I/O に落ちるときだけ Task が生成されるため、平均割り当てを大きく抑制。
パターンB:戻り値なしの場合は ValueTask または Task.CompletedTask
public ValueTask TouchAsync(int id)
{
if (!_needsUpdate.Contains(id))
return default; // 完了済み ValueTask(割り当てなし)
return new ValueTask(WriteToDbAsync(id)); // 非同期なら Task を包む
}
public Task InvalidateAsync(int id)
{
if (_set.Remove(id))
return Task.CompletedTask; // 割り当てなし
return ReallyInvalidateAsync(id); // ここでだけ Task が生まれる
}
パターンC:「同期完了はまれ」なら Task.FromResult で十分
public Task<UserDto?> GetUserAsync(int id)
{
if (_memoryCache.TryGetValue(id, out var cached))
return Task.FromResult<UserDto?>(cached); // 軽量だが割り当ては発生
return FetchFromDbAsync(id);
}
判断の目安
| 同期完了頻度 | 例 | 推奨型 |
|---|---|---|
| ほとんど常に非同期 | 外部 API, DB, ファイル | Task<T> |
| 高頻度で同期完了 | メモリキャッシュ 50–90% 命中 | ValueTask<T> |
| 同期完了は 1–5% 程度 | 初回 I/O、以降キャッシュ命中はまれ | Task<T> + Task.FromResult |
ValueTask を採用する際の注意点(重要)
- 1 回だけ消費:
ValueTask<T>は 単回 の待機/取得が前提です。複数回のawait、複数回の.Result取得、複数回のGetAwaiter().GetResult()は避けてください。繰り返し必要なら.AsTask()でTask<T>に変換(その場合は割り当てが発生)します。 - 長期保持しない:
ValueTaskは軽量ハンドルです。フィールドに保持して後から使い回すのは避けましょう。 - API の互換性:公開 API の戻り値を
Task<T>→ValueTask<T>に変えるのは破壊的変更になり得ます。ライブラリではメジャーバージョンで検討。 - デリゲート互換性:
Func<Task<T>>を受け取る既存 API にValueTaskを直接渡せない場合があります。そのときはアダプター層を追加。 - 例外の扱い:
awaitまで例外は表面化しません。これまでと同様にtry/catchの位置はawait後で設計します。
アダプター例:Func<Task<T>> に ValueTask<T> を接続
static Func<Task<T>> AsTaskFactory<T>(Func<ValueTask<T>> producer)
=> () => producer().AsTask();
「CPU バウンドなのに async」を避ける
CPU 計算のみで完結する処理に async/await を付けても並列化されません。むしろコールバックやステートマシン分のオーバーヘッドが増えます。I/O が関与しないパスは同期メソッドで完結させます。
// 悪い例:CPUだけなのにasync
public async Task<int> CalcAsync(int n) => Fib(n); // await 無し(警告 & 無駄)
// 良い例:同期で完結
public int Calc(int n) => Fib(n);
設計テンプレート:二層構造(窓口は軽量、重い処理は奥に)
同期完了があり得る“窓口”を ValueTask で用意し、実際の I/O は奥の Task ベース非公開メソッドに集約するパターンが扱いやすく、かつ割り当てを抑えられます。
public sealed class UserService
{
private readonly IMemoryCache _cache;
private readonly DbContext _db;
public ValueTask<UserDto?> GetAsync(int id, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (_cache.TryGetValue(id, out UserDto? cached))
return new ValueTask<UserDto?>(cached);
// 非同期のコア処理へ委譲
return new ValueTask<UserDto?>(GetCoreAsync(id, ct));
}
private async Task<UserDto?> GetCoreAsync(int id, CancellationToken ct)
{
var entity = await _db.Users.FindAsync(new object?[] { id }, ct).ConfigureAwait(false);
return entity is null ? null : Map(entity);
}
}
- CancellationToken の扱い:同期パスでも
ct.ThrowIfCancellationRequested()を呼び、呼び出し側の期待に合わせます。 - ConfigureAwait(false):ライブラリ内部では推奨。ASP.NET Core では必須ではないが害はありません。
チェックリスト:安全に高速化するための実務ルール
- I/O の有無を正しく分類(I/O 無しなら同期化)
- “窓口は
ValueTask、奥はTask” の二層パターンを基本に - 戻り値なしの同期完了は
Task.CompletedTask(またはdefault(ValueTask)) - 戻り値ありの同期完了は
new ValueTask<T>(value)またはTask.FromResult - 同期コンテキストがある UI/旧ASP.NET では
ConfigureAwait(false)を検討 - 公開 API の型変更は互換性に注意(
Task↔ValueTask) - 測定して判断:呼び出し頻度 × 同期完了率 × 一時割り当て量
よくある誤解とアンチパターン
| 誤解/アンチパターン | 問題点 | 代替案 |
|---|---|---|
「Task.FromResult は割り当てゼロ」 | 完了済み Task<T> を生成するため割り当てあり | 高頻度なら ValueTask<T>。戻り値なしは Task.CompletedTask |
ValueTask<T> をフィールドで長期保存 | 単回消費が前提。後で読むと未定義動作の温床 | 保存が必要なら Task<T> を保持 |
async なのに .Result/.Wait() | デッドロック/スレッド枯渇・スループット低下 | 常に await で終端する |
CPU バウンド処理を async 化 | 利点なし。むしろオーバーヘッド増 | 同期メソッド化、必要ならバッチ・並列化を明示的に |
Q&A:最初の疑問に明確に答える
1. 「キャッシュ済み結果を返す」「同期的に完了する」とは?
メモリキャッシュやガード節が該当します。非同期 I/O を伴わず 即座に戻れる分岐です。例:TryGetValue 命中、既存フラグにより「何もしない」で終了、バリデーション NG で即時エラーなど。
2. なぜ async メソッドで問題になり、ドキュメントで注意喚起されるのか?
- 同期完了でも Task 割り当て が発生し、tight loop で GC 負荷になる。
- 状態機械とコンテキスト捕捉のコストが無駄に乗る。
- 本来 I/O の効率化が目的なのに、I/O がないパスでデメリットのみ生む。
3. 改善策・実装パターンは? Task と Task<T> の違いは?
- 改善策:同期完了が多いなら
ValueTask<T>/戻り値なしはTask.CompletedTask。同期完了がまれならTask.FromResultで簡潔に。 - 違い:
TaskとTask<T>の割り当てコストは本質的に同じ。型安全性と戻り値有無の違いが主。
ケーススタディ:メモリキャッシュ命中 80% の API
以下は「ほとんどがキャッシュ命中」な読み取り API の設計例です。
public sealed class ProductQuery
{
private readonly IMemoryCache _cache;
private readonly IDbConnection _db;
// 1. 窓口:ValueTask で割り当て抑制
public ValueTask<ProductDto?> GetAsync(int id, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (_cache.TryGetValue(id, out ProductDto? cached))
return new ValueTask<ProductDto?>(cached);
return new ValueTask<ProductDto?>(GetCoreAsync(id, ct));
}
// 2. 非公開のI/Oコア:Task ベースで素直に書く
private async Task<ProductDto?> GetCoreAsync(int id, CancellationToken ct)
{
var row = await _db.QuerySingleOrDefaultAsync(
"select * from products where id = @id", new { id }, ct)
.ConfigureAwait(false);
return row is null ? null : Map(row);
}
}
- 命中時:割り当てゼロ、スレッド切替なし。呼び出し側の
awaitも即時完了。 - 不命中時:最小限の Task 割り当てで I/O に落ちる。
キャッシュ設計の落とし穴と回避策
| 落とし穴 | 症状 | 回避策 |
|---|---|---|
| Task のキャッシュ | 失敗/キャンセルの再利用で予期せぬ例外拡散 | データ(DTO)をキャッシュ。どうしても非同期初期化を共有したいなら Lazy<Task<T>> などで失敗時の再試行を設計 |
| ValueTask のキャッシュ | 単回消費違反、バグの温床 | キャッシュ対象は 結果(値)に限定 |
| CancellationToken の無視 | タイムアウトやキャンセルが同期パスで効かない | 同期パスでも ct.ThrowIfCancellationRequested() を必ず呼ぶ |
さらに踏み込む:IValueTaskSource でゼロ割り当てを徹底
チャネル・パイプなど超高頻度の非同期 API では、IValueTaskSource<T> と ManualResetValueTaskSourceCore<T> を用いて 非同期でも Task を割り当てない 実装が採用されます(上級者向け)。
using System.Threading.Tasks.Sources;
public sealed class MySource : IValueTaskSource
{
private ManualResetValueTaskSourceCore _core;
public ValueTask<int> WaitAsync()
=> new ValueTask<int>(this, _core.Version);
public void SetResult(int value) => _core.SetResult(value);
public void SetException(Exception ex) => _core.SetException(ex);
int IValueTaskSource<int>.GetResult(short token) => _core.GetResult(token);
ValueTaskSourceStatus IValueTaskSource<int>.GetStatus(short token) => _core.GetStatus(token);
void IValueTaskSource<int>.OnCompleted(Action<object?> c, object? s, short t, ValueTaskSourceOnCompletedFlags f)
=> _core.OnCompleted(c, s, t, f);
}
一般的な業務アプリでは過剰ですが、ランタイム/ミドルウェア/高頻度ライブラリでは検討価値があります。
計測のすすめ:ベンチマーク雛形
最適化は計測してこそ。以下はキャッシュ命中率を変えて割り当てとスループットを比較する雛形です(BenchmarkDotNet を想定)。
[MemoryDiagnoser]
public class CacheBench
{
private readonly Dictionary<int, string> _cache = new();
private readonly Random _rand = new(0);
[Params(10, 50, 90)] public int HitRate;
[Benchmark]
public async Task TaskPattern()
{
int key = _rand.Next(0, 100);
if (_rand.Next(100) < HitRate && _cache.TryGetValue(key, out var v))
await Task.FromResult(v);
else
await SimulateIoAsync();
}
[Benchmark]
public async Task ValueTaskPattern()
{
int key = _rand.Next(0, 100);
if (_rand.Next(100) < HitRate && _cache.TryGetValue(key, out var v))
await new ValueTask<string>(v);
else
await new ValueTask<string>(SimulateIoAsync());
}
private static async Task<string> SimulateIoAsync()
{
await Task.Delay(1).ConfigureAwait(false);
return "x";
}
}
ポイントは「同期パスでの割り当て抑制」による GC 圧の低減です。大量呼び出しで差が体感できるはずです。
ASP.NET Core における実装 Tips
- シリアライズ/デシリアライズの前後で同期化:キャッシュ命中時は同期で DTO を返し、ミス時のみ I/O に落とす。
- フィルター/ミドルウェア:短絡判定(認可・フラグ)で即終了するなら
ValueTaskで早期 return。 - ConfigureAwait:通常は不要。ライブラリ呼び出しの内部や再利用コードでは
ConfigureAwait(false)を付けると移植性が増す。
サンプル集:よく使うスニペット
キャッシュ命中なら同期、ミスなら非同期
public ValueTask<T> GetOrFetchAsync<T>(string key, Func<Task<T>> fetch)
{
if (_cache.TryGetValue(key, out T value))
return new ValueTask<T>(value);
return new ValueTask<T>(GetOrFetchCoreAsync(key, fetch));
}
private async Task GetOrFetchCoreAsync(string key, Func> fetch)
{
var value = await fetch().ConfigureAwait(false);
_cache.Set(key, value);
return value;
}
戻り値なし:早期リターンで割り当てゼロ
public ValueTask EnsureIndexAsync(int id)
{
if (_index.Contains(id))
return default; // 完了済み
return new ValueTask(BuildIndexAsync(id));
}
公開 API は Task を維持、内部で ValueTask
public Task<UserDto?> FindAsync(int id)
=> FindInternalAsync(id).AsTask();
private ValueTask FindInternalAsync(int id)
{
if (_cache.TryGetValue(id, out var dto))
return new ValueTask(dto);
return new ValueTask<UserDto?>(_db.Users.FindAsync(id));
}
比較表:どの型を返すべきか
| 状況 | 返す型 | 理由 | 留意点 |
|---|---|---|---|
| 外部 I/O が中心 | Task<T> | 実装がシンプル、互換性が高い | 同期パスがまれならこれで十分 |
| 同期完了が高頻度 | ValueTask<T> | 同期パスで割り当てゼロ | 単回消費の制約、アダプタが必要になる事がある |
| 戻り値なし・同期完了 | Task.CompletedTask / default(ValueTask) | 割り当てゼロ | どちらでもよいが一貫性を持たせる |
| 同期完了はまれ | Task.FromResult | 簡潔で読みやすい | 割り当てはあるので多用は避ける |
実務のまとめ
- 同期的に終わるパスが多い/tight loop で呼ばれるなら 迷わず
ValueTaskを検討。 Task/Task<T>の違いは型安全性で、割り当てコストは本質的に同等。戻り値なしはTask.CompletedTaskを活用。- I/O バウンドと CPU バウンドを切り分け、不要な
async/awaitをやめる。 - API 互換性・単回消費・アダプタといった
ValueTaskの注意点を理解したうえで導入。 - 最後は測定。ヒープ割り当て・GC 回数・P99 レイテンシで効果を確認してから広範囲へ適用。
付録:チェック用ミニリント
コードレビューで次を見つけたら要検討です。
async Task<T> FooAsync()内でTryGetValue命中時に 即 return(I/O なし)Task.FromResultが tight loop に多数- 戻り値なしの
asyncメソッドでawait経由せず早期 return ValueTaskをフィールドに保持- 同期パスで
CancellationTokenが無視されている
付録:小さなリファクタ例(前→後)
Before
public async Task<Profile?> GetAsync(int id, CancellationToken ct)
{
if (_profiles.TryGetValue(id, out var p))
return p; // 同期だが常に Task 割り当て
return await FetchAsync(id, ct); // I/O
}
After
public ValueTask<Profile?> GetAsync(int id, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (_profiles.TryGetValue(id, out var p))
return new ValueTask<Profile?>(p); // 割り当てゼロ
return new ValueTask<Profile?>(FetchAsync(id, ct)); // 非同期時のみ Task
}
最後に
非同期は魔法ではありません。同期で終わる道 が多いなら、そこを最短距離で走らせる設計に変えるだけで、GC 圧は目に見えて下がり、P95/P99 の安定にも効きます。ValueTask・Task.CompletedTask・Task.FromResult を使い分け、「I/O は非同期、同期は同期」という本質に立ち返った API を育てていきましょう。

コメント