1. はじめに
コードに飛び込む前にざっくり整理しましょう。.NETでオブジェクトを作ること(ましてやTaskを作ること)はメモリと少しのCPU時間を消費することを覚えていますか?ここで、半分は結果を瞬時に返す(例えばキャッシュから取る)非同期APIを想像してください。もう半分はデータベースに問い合わせるので、本当に非同期になりTaskが必要になります。ファイルキャッシュ、ネットワークAPI、オブジェクトプールなど、「非同期だけど時々瞬時に済む」シナリオでよく出会います。
常にTaskを返すと、瞬時に結果がある場合でも余計なオブジェクトを生成しなければなりません。もし結果が既に準備できているなら、Taskを返さずに結果を返せたらどうでしょう?それがValueTaskが生まれた理由です。
事実: 標準のTask.CompletedTaskやTask.FromResult(…)は実行時間を節約しますが、共有オブジェクトを作るため、高負荷シナリオでは完全には理想的でない場合があります。
そもそもValueTaskとは
ValueTaskは、すでに準備された結果か、(実際に非同期ならば)Task自体のどちらかを表せる特殊なラッパーstructです。簡単に言えば、結果そのものか、Taskへの参照を含められる「パッケージ」です。
主に二種類あります:
- ValueTask — 値を返さない(Taskと同様)
- ValueTask<TResult> — 値用のラッパー(Task<TResult>の類似)
TaskとValueTaskの比較
| 種類 | アロケーション数 | 同期的に完了する可能性 | 通常の用途 |
|---|---|---|---|
|
1(ヒープ) | する/しない | ほとんど常に |
|
0/1 | する/しない | 最適化用 |
|
0/1 | する/しない | 最適化用 |
いつValueTaskを使うか
ゴールデンルール:常にasync操作の後でしか結果を返さない通常の非同期メソッドを書くなら、Taskを使ってください。これは簡単で安全、みんなが理解しやすいです。
ValueTaskを使う価値があるのは:
- 結果が同期的に得られる可能性が高い場合(例えばキャッシュ、プール、メモリからの取得)で、余分なアロケーションを節約したいとき。
- 非同期操作があまり頻繁でない場合(そうでなければコードの複雑化やstructのコピーで得る利益が失われる)。
注意! もし常に非同期結果を返すなら迷わずTaskを使ってください。頻繁に瞬時に返る結果のためにAPIを「超最適化」したいなら、ValueTaskが候補です。
2. 同期的な結果か非同期的な結果か
名前でユーザーを探す関数を考えます。ユーザーがキャッシュにいれば瞬時に返し、いなければ「DBから」非同期に読み込みます:
// モデルとしてのユーザー
public class User
{
public string Name { get; set; }
}
// シンプルなキャッシュ
private readonly Dictionary<string, User> _localCache = new();
public async ValueTask<User> FindUserAsync(string name)
{
// ローカルキャッシュをチェック
if (_localCache.TryGetValue(name, out var user))
{
// 結果は即時 — Taskのアロケーションは無し!
return user;
}
// ここでは長い非同期操作(例: DBからの取得)
user = await LoadUserFromDbAsync(name);
// 将来のためにキャッシュに入れる
_localCache[name] = user;
return user;
}
private async Task<User> LoadUserFromDbAsync(string name)
{
// 遅延をシミュレート
await Task.Delay(500);
return new User { Name = name };
}
重要: キャッシュヒット時はただの結果を返すのでTaskのアロケーションは発生しません!結果を「取得する」必要があるときだけ非同期タスクを作ります。
ValueTaskの内部構造
ValueTaskの内部には、準備済みの値か、Taskへの参照のどちらかが格納されます:
ValueTask result = ValueTask.CompletedTask;
ValueTask<int> valueResult = new ValueTask<int>(42);
ValueTask<int> valueResult2 = new ValueTask<int>(Task.Run(() => 42));
awaitを使うときはコンパイラが自動で判断します:結果が瞬時なら余分なオブジェクトは生成されません。
awaitとValueTaskについて重要な点
asyncメソッドはawaitとValueTaskを普通に扱えます:
public async ValueTask PingAsync()
{
// ...
await Task.Delay(10);
}
しかし、ValueTaskのインスタンスを保存して後で待機する場合には注意:同じインスタンスに対して複数回awaitすることはできません。Taskは何度も待機できますが、ValueTaskはそうではありません。
エラー: ValueTaskの二重await
ValueTask<int> task = ComputeAsync();
// これはOK
int a = await task;
// これはエラー!同じValueTaskインスタンスに対する二度目のawaitは許されない:
// int b = await task; // ダメ!
3. コンソールアプリの一部を書き換える
簡易的なブックリーダーを作っているとしましょう:一部のテキストは既にキャッシュにあり、残りは非同期でフェッチします。ValueTaskを使って、本の最初の行を取得する最適化メソッドを実装します:
private readonly Dictionary<string, string> _bookCache = new();
public async ValueTask<string> GetFirstLineOfBookAsync(string title)
{
if (_bookCache.TryGetValue(title, out var bookText))
{
var firstLine = bookText.Split('\n')[0];
return firstLine;
}
// 仮に本を非同期にダウンロードする
var downloadedBook = await DownloadBookTextAsync(title);
_bookCache[title] = downloadedBook;
return downloadedBook.Split('\n')[0];
}
private async Task<string> DownloadBookTextAsync(string title)
{
// ダウンロード(例: インターネットから)の遅延をシミュレート
await Task.Delay(1000);
return $"Book: {title}\nThis is the first line.\nSecond line...";
}
このように、書籍が既にキャッシュにあればValueTaskはTask生成を節約します。
4. 便利な注意点
注意マーカー: ValueTaskを使うべきでない場合
APIが常に非同期(例: 常にネットワークにアクセスする)なら、Taskを使ってください。簡単で安全です。
ValueTaskをTaskに、またその逆に変える方法
場合によっては、ValueTaskが直接渡せないAPIがあり、Taskが必要です。そんなときは.AsTask()を使います:
ValueTask<int> valueTask = ComputeAsync();
Task<int> task = valueTask.AsTask();
逆に、TaskからValueTaskを作りたいときは、単にTaskからValueTaskを作成します:
Task<int> task = ComputeAsyncTask();
ValueTask<int> valueTask = new ValueTask<int>(task);
ValueTask と Task の比較
| 特徴 | |
|
|---|---|---|
| 二重await | 許可される | 許可されない |
| 高速応答時のアロケーション | 発生する | 発生しない(結果が即時なら) |
| 互換性インターフェイス | 広くサポートされる | 追加のラッパーが必要な場合がある |
| 適用範囲 | どこでも使える | 最適化向け |
| プーリング(Pool) | 共通プールを使う | いいえ、struct |
| 簡単さ | 簡単 | より複雑 |
実務でのValueTaskの使い方
public async ValueTask<string> GetMessageAsync(int id)
{
if (_messageCache.TryGetValue(id, out var value))
return value;
var result = await LoadMessageFromDbAsync(id);
_messageCache[id] = result;
return result;
}
面接での使いどころ: キャッシュ付き非同期APIのパフォーマンスをさらに高める方法を聞かれたら、ValueTaskについて言及しましょう。
LINQや IAsyncEnumerable<T> との互換例
ValueTaskを非同期LINQ(IAsyncEnumerable<T>)と組み合わせて使いたいなら、.NETは対応しています(例: メソッドToListAsyncなど)。
public async ValueTask<List<int>> GetPrimeNumbersAsync()
{
// 部分的に計算済み、残りは非同期で計算されることをシミュレート
// ... (例は省略)
return new List<int> { 2, 3, 5, 7, 11 };
}
5. まとめと実装上の注意点
- 結果がかなりの時間で瞬時に準備できる(例: キャッシュ)場合は、最適化のためにValueTaskを使う。
- 必要もないのにValueTaskを使わない。コードが複雑になりバグの元になる。
- awaitを同じValueTaskに複数回使わない。必要ならTaskに変換する。
- Taskとの互換性は.AsTask()経由で行う。
- ValueTaskはstructであり、コピー時に挙動が変わる可能性があることを忘れない。
6. ValueTaskでよくある間違い
間違いその1: 同一インスタンスに対する二重await。 ValueTaskはstructであり、二度awaitすると例外や不正な動作を招く。
間違いその2: 互換性確認なしにValueTaskを使う。 外部APIがTaskを要求する場合、直接渡すと問題になることがある。
間違いその3: 必要ないのにValueTaskを使う。 結果が常に非同期なら、ValueTaskはコードを複雑にするだけで恩恵がない。
間違いその4: structとしてのValueTaskをコピーすること。 コピーされた構造体はawait時に予期せぬ振る舞いをする可能性があるので、元のインスタンスを扱うことが重要です。
GO TO FULL VERSION