【C#】Task完全ガイド|ValueTask・TaskCompletionSource・コンビネーター・async eliding・状態検査まで

【C#】非同期メソッドで戻り値を扱う方法(Task<T>の活用) C#

Task<T> は C# の非同期プログラミングにおける中心的な型で、「将来的に T 型の値が得られる約束(Promise)」を表します。async/await と組み合わせて使うのが一般的ですが、Task<T> 自体はステートマシンの制御・手動生成・結合・状態検査など多くの API を持つ型であり、その全体像を把握することでパフォーマンス最適化やライブラリ設計が格段に向上します。

本記事では Task / Task<T> / ValueTask<T> の使い分け、TaskCompletionSource<T> による手動 Promise、WhenAll/WhenAny/WhenEach コンビネーター、ContinueWithawait の違い、async eliding、Task<T> の共変性問題、状態検査、.Result のデッドロックリスクまで体系的に解説します。

スポンサーリンク

Task / Task<T> / ValueTask<T> の使い分け

戻り値 ヒープ確保 主な使い所
Task なし(完了のみ通知) 常にヒープ 戻り値不要の非同期メソッド
Task<T> T を返す 常にヒープ 大半の非同期メソッドの戻り値
ValueTask なし 同期完了時はヒープ不要 ホットパスで完了済みを返すことが多い場合
ValueTask<T> T を返す 同期完了時はヒープ不要 キャッシュヒット等で即値を返すケースが多い場合
各型の使い分け
// ① Task: 値を返さない非同期処理
public async Task SendEmailAsync(string to, string body)
{
    await _smtp.SendMailAsync(CreateMessage(to, body));
}

// ② Task<T>: 値を返す非同期処理(最も一般的)
public async Task<User> GetUserAsync(int id)
{
    return await _db.Users.FindAsync(id)
        ?? throw new KeyNotFoundException($"User {id} not found");
}

// ③ ValueTask<T>: 同期的に完了するケースが多い場面でのみ使う
public ValueTask<int> GetCachedCountAsync()
{
    if (_cache.TryGetValue("count", out int cached))
        return new ValueTask<int>(cached);  // ヒープ確保なし(struct のまま)

    return new ValueTask<int>(LoadCountFromDbAsync()); // 非同期パスのみ Task に包まれる
}

private async Task<int> LoadCountFromDbAsync()
{
    return await _db.Items.CountAsync();
}
ValueTask<T> の制約を理解してから使う
ValueTask<T>Task<T> の万能な上位互換ではありません。① 1回しか await できない(2回目は未定義動作)、② 同時に2箇所で await できない.Result は完了後のみ安全という制約があります。Task.WhenAll に渡すことも直接はできません(.AsTask() 変換が必要)。ライブラリの public API では Task<T> を返し、内部の高速パスだけ ValueTask<T> を使うのが安全な判断基準です。

Task<T> の生成パターン — async 以外の方法

既知の値から Task を作る
// ① Task.FromResult: 既に結果が分かっているとき(キャッシュヒット等)
public Task<int> GetDefaultPortAsync() => Task.FromResult(5432);

// ② Task.CompletedTask: 値なしで即完了(Task 型のインターフェース実装で使う)
public Task InitializeAsync() => Task.CompletedTask;

// ③ Task.FromException: 例外を表す Task を即座に生成
public Task<User> GetUserAsync(int id)
{
    if (id <= 0)
        return Task.FromException<User>(new ArgumentException("id must be > 0"));
    return FetchUserFromDbAsync(id);
}

// ④ Task.FromCanceled: キャンセル済みの Task を即座に生成
public Task<byte[]> ReadFileAsync(CancellationToken ct)
{
    if (ct.IsCancellationRequested)
        return Task.FromCanceled<byte[]>(ct);
    return File.ReadAllBytesAsync("data.bin", ct);
}

// ⑤ Task.Run: CPU-bound 処理をスレッドプールで実行
public Task<double> CalculatePiAsync(int iterations) =>
    Task.Run(() => ComputePi(iterations));
Task.FromResult はヒープ確保する
Task.FromResult(value)Task<T> の新しいインスタンスを生成するため、頻繁に呼ばれるパスでは GC 圧迫の原因になります。boolint の小さな値は .NET 内部でキャッシュされますが、独自型や大きな値は毎回ヒープ確保されます。頻度が高いなら ValueTask<T> か、結果を static readonly Task<T> にキャッシュしてください。

TaskCompletionSource<T> — 手動で Promise を作る

コールバック API を Task に変換する
// TaskCompletionSource<T> は「外部から結果を設定できる Task<T>」を作る
// コールバック・イベント・レガシー API を Task ベースに変換するのに不可欠

// 例: WebSocket の OnMessage コールバックを await 可能にする
public Task<string> ReceiveOneAsync(CancellationToken ct)
{
    var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);

    // キャンセル時のクリーンアップ
    ct.Register(() => tcs.TrySetCanceled(ct));

    _socket.OnMessage += (_, msg) =>
    {
        tcs.TrySetResult(msg.Data);   // 最初のメッセージで完了
    };
    _socket.OnError += (_, err) =>
    {
        tcs.TrySetException(new IOException(err.Message));
    };

    return tcs.Task;
}

// 使用側は普通の await で受け取れる
string message = await ReceiveOneAsync(cts.Token);

// TrySetResult vs SetResult:
// SetResult は2回呼ぶと InvalidOperationException
// TrySetResult は2回目以降は false を返すだけ(安全)
TaskCreationOptions.RunContinuationsAsynchronously
// RunContinuationsAsynchronously をつけないと、
// TrySetResult を呼んだスレッド上で await の続きが同期的に走る
// → ロック中に TrySetResult を呼ぶとデッドロックの危険

// NG: 継続がロック内で走る可能性
lock (_gate)
{
    _tcs.TrySetResult(42);
    // ↑ ここで await の続きが同期実行されると _gate を再取得しようとしてデッドロック
}

// OK: 継続は別スレッドにスケジュールされる
var tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
lock (_gate)
{
    tcs.TrySetResult(42);  // 継続はスレッドプールに投げられるので安全
}
TaskCompletionSource には必ず RunContinuationsAsynchronously をつける
特別な理由がない限り、TaskCompletionSource<T> のコンストラクタにはTaskCreationOptions.RunContinuationsAsynchronously を渡してください。これにより TrySetResult を呼んだスレッド上で await の継続が同期実行される問題を防ぎ、ロック内から安全に結果を設定できます。

Task コンビネーター — WhenAll / WhenAny / WhenEach

WhenAll — 全タスクの完了を待つ
// 複数の Task<T> を同時に実行して全結果を配列で受け取る
var tasks = new[]
{
    FetchUserAsync(1),
    FetchUserAsync(2),
    FetchUserAsync(3),
};
User[] users = await Task.WhenAll(tasks);

// 型が異なる場合はタプルで分解(型推論が効く)
var (user, orders, balance) = (
    await GetUserAsync(id),
    await GetOrdersAsync(id),
    await GetBalanceAsync(id)
);
// ↑ これは逐次実行!同時に走らせるには↓
Task<User> userTask       = GetUserAsync(id);
Task<Order[]> ordersTask  = GetOrdersAsync(id);
Task<decimal> balanceTask = GetBalanceAsync(id);
await Task.WhenAll(userTask, ordersTask, balanceTask);
var user2    = userTask.Result;    // 完了後の .Result は安全
var orders2  = ordersTask.Result;
var balance2 = balanceTask.Result;
WhenAny — 最初に終わった1つを取る
// 複数のバックアップ API から最速レスポンスを採用
var tasks = new[]
{
    FetchFromPrimaryAsync(),
    FetchFromSecondaryAsync(),
    FetchFromCacheAsync(),
};
Task<string> firstCompleted = await Task.WhenAny(tasks);
string result = await firstCompleted;  // 2回目の await は即完了

// タイムアウト実装(Task.Delay と競争させる)
var work = DoExpensiveWorkAsync();
var timeout = Task.Delay(TimeSpan.FromSeconds(5));
if (await Task.WhenAny(work, timeout) == timeout)
    throw new TimeoutException("5秒を超過しました");
string answer = await work;  // ここでは完了しているはず

// .NET 6+: Task.WaitAsync がこのパターンを1行に
string answer2 = await DoExpensiveWorkAsync()
    .WaitAsync(TimeSpan.FromSeconds(5));
WhenEach(.NET 9+)— 完了順に非同期列挙
// .NET 9+: Task.WhenEach で完了順に IAsyncEnumerable として取得
var tasks = urls.Select(FetchAsync).ToList();

await foreach (Task<string> completed in Task.WhenEach(tasks))
{
    try
    {
        string html = await completed;
        Console.WriteLine($"完了: {html.Length} chars");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"失敗: {ex.Message}");
    }
}
// → 最初に終わったタスクから順に処理できる(WhenAll は全部待つ必要があった)

ContinueWith vs await — なぜ await が推奨か

ContinueWith(レガシー)と await の比較
// ContinueWith: Task Parallel Library (TPL) 時代の継続API
var result = await Task.Run(() => Compute())
    .ContinueWith(prev =>
    {
        if (prev.IsFaulted) throw prev.Exception!.InnerException!;
        return prev.Result * 2;
    }, TaskContinuationOptions.OnlyOnRanToCompletion);

// 問題:
// - SynchronizationContext を自動で考慮しない(UIスレッドに戻らない)
// - 例外処理が AggregateException で複雑
// - コードが読みにくい(ネスト・コールバック地獄)
// - キャンセルハンドリングが難しい

// await: 同じ処理がはるかにシンプル
int computed = await Task.Run(() => Compute());
int doubled  = computed * 2;

// await は SynchronizationContext を自動キャプチャ(UI スレッドに戻る等)
// 例外は普通の try-catch で扱える
// キャンセルは CancellationToken を渡すだけ

// ContinueWith が必要なケース: ほぼゼロ
// 唯一の例外: 「前のタスクの状態(成功/失敗/キャンセル)で分岐する」高度なシナリオ
// → それでも await + try-catch の方がはるかに読みやすい

async eliding — async/await を省略するパターン

async/await を省略できるケースとリスク
// async を付けると内部でステートマシンが生成されるためわずかなオーバーヘッドがある
// 単に別の Task<T> を返すだけなら async/await を省略できる(async eliding)

// ① 省略可能: 別の非同期メソッドの結果をそのまま返すだけ
public Task<User> GetUserAsync(int id)
    => _repository.FindAsync(id);  // async 不要、Task をそのまま返す

// ② 省略してはいけない: try-catch / using / finally がある場合
// NG: using のスコープが呼び出し元まで延びず、先に Dispose されてしまう
public Task<string> ReadFileAsync(string path)
{
    using var stream = File.OpenRead(path);
    return ReadToEndAsync(stream); // stream が Dispose されてから読み取りが走る
}

// OK: async を付けると await 完了まで using スコープが保たれる
public async Task<string> ReadFileAsync(string path)
{
    using var stream = File.OpenRead(path);
    return await ReadToEndAsync(stream); // OK: await 完了後に Dispose される
}

// ③ 省略してはいけない: 例外を Task でラップしたい場合
// NG: async なしだとバリデーション例外が同期的にスロー(呼び出し側のスタックで即発生)
public Task<int> ParseAsync(string s)
{
    if (s is null) throw new ArgumentNullException();
    return Task.FromResult(int.Parse(s));
}

// OK: async を付けると例外が Task<int> に包まれる
public async Task<int> ParseAsync(string s)
{
    if (s is null) throw new ArgumentNullException();
    return int.Parse(s);
    // → 呼び出し側は await で例外を受け取る(同期的にスローされない)
}
ケース async 省略 理由
別メソッドの Task をそのまま返す ○ 省略可 ステートマシンのオーバーヘッド削減
using / IDisposable がある × 省略不可 スコープ管理が壊れる
try-catch がある × 省略不可 例外が Task にラップされない
引数バリデーション後に Task を返す △ 意図次第 同期例外を許容するなら省略可

Task<T> の状態検査

IsCompleted / IsFaulted / IsCanceled / Status
var task = DoWorkAsync();

// 完了を待たずに状態を確認できる
Console.WriteLine(task.IsCompleted);              // 完了したか(成功/失敗/キャンセル問わず)
Console.WriteLine(task.IsCompletedSuccessfully);  // 成功完了のみ(.NET Standard 2.1+)
Console.WriteLine(task.IsFaulted);                // 例外で完了したか
Console.WriteLine(task.IsCanceled);               // キャンセルされたか
Console.WriteLine(task.Status);                   // TaskStatus enum

// Task<T>.Result: 完了済みなら安全に値を取れる
if (task.IsCompletedSuccessfully)
    Console.WriteLine(task.Result);  // ブロックしない(完了済みなので即取得)

// Task<T>.Exception: IsFaulted = true の場合に AggregateException を取得
if (task.IsFaulted)
    Console.WriteLine(task.Exception!.InnerException!.Message);

// TaskStatus の値:
// Created → WaitingForActivation → WaitingToRun → Running
// → RanToCompletion | Faulted | Canceled
.Result と .Wait() は完了前に呼ぶとデッドロックの危険
task.Resulttask.Wait()タスクが完了するまで呼び出しスレッドをブロックします。UI スレッドや旧 ASP.NET で SynchronizationContext がキャプチャされている場合、await の継続が同じスレッドで実行されようとしてデッドロックします。解決策は常に await を使うか、IsCompletedSuccessfully を確認してから .Result にアクセスすることです。

Task<T> の共変性問題 — Task<Derived> は Task<Base> ではない

Task はクラスなので共変ではない
// C# のジェネリッククラスは共変ではないので Task<Derived> → Task<Base> への暗黙変換は不可
public class Animal { }
public class Dog : Animal { }

async Task<Dog> GetDogAsync() => new Dog();

// NG: Task<Dog> は Task<Animal> に暗黙変換できない
// Task<Animal> task = GetDogAsync(); // コンパイルエラー

// OK ①: async メソッドの中なら await で T を取り出すので共変的に使える
async Task<Animal> GetAnimalAsync()
{
    Dog dog = await GetDogAsync();
    return dog;  // Dog → Animal は暗黙変換可能
}

// OK ②: Task<T> 自体を変換する拡張メソッド
public static async Task<TBase> AsBase<TDerived, TBase>(this Task<TDerived> task)
    where TDerived : TBase
{
    return await task;
}

Task<Animal> animalTask = GetDogAsync().AsBase<Dog, Animal>();

// IValueTaskSource<T> / ValueTask<T> も同様に共変ではない

Task<IEnumerable<T>> vs IAsyncEnumerable<T>

ストリーミングが必要か一括取得かで使い分け
// ① Task<List<T>>: 全データが揃ってから返す(一括取得)
public async Task<List<User>> GetAllUsersAsync()
{
    return await _db.Users.ToListAsync(); // 全件メモリに載ってから返る
}

// ② IAsyncEnumerable<T>: 1件ずつ非同期に返す(ストリーミング)
public async IAsyncEnumerable<User> StreamUsersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var user in _db.Users.AsAsyncEnumerable().WithCancellation(ct))
    {
        yield return user;  // 1件ずつ返す(呼び出し側で await foreach で受ける)
    }
}

// 使い分け:
// 全件の結果が必要(集計・ソート・件数取得) → Task<List<T>>
// 大量データを少しずつ処理(CSV出力・ストリーム送信) → IAsyncEnumerable<T>
// HTTP API のストリーミングレスポンス → IAsyncEnumerable<T>

// IAsyncEnumerable は Task<T> と同じく await foreach で消費する
await foreach (var user in StreamUsersAsync(ct))
    Process(user);

実践パターン集

パターン① — キャッシュ付き非同期ロード
// ConcurrentDictionary + Lazy<Task<T>> で重複リクエストを防ぐ
public sealed class AsyncCache<TKey, TValue> where TKey : notnull
{
    private readonly ConcurrentDictionary<TKey, Lazy<Task<TValue>>> _cache = new();

    public Task<TValue> GetOrAddAsync(TKey key, Func<TKey, Task<TValue>> factory)
    {
        var lazy = _cache.GetOrAdd(key,
            k => new Lazy<Task<TValue>>(() => factory(k)));
        return lazy.Value;  // 同じキーで同時アクセスしても factory は1回だけ実行される
    }
}

var cache = new AsyncCache<string, User>();
User user = await cache.GetOrAddAsync("user-42", id => FetchUserAsync(id));
パターン② — Task.FromResult のキャッシュで GC 削減
// bool や小さい整数はランタイムが内部キャッシュするが、カスタム型はされない
// 高頻度で同じ値を返す場合は static readonly でキャッシュ

public static class CachedTasks
{
    public static readonly Task<bool> True  = Task.FromResult(true);
    public static readonly Task<bool> False = Task.FromResult(false);
    public static readonly Task<string> Empty = Task.FromResult(string.Empty);
}

// 使用
public Task<bool> IsValidAsync(string? input)
    => string.IsNullOrEmpty(input) ? CachedTasks.False : ValidateAsync(input);
パターン③ — Task.Yield で長い同期処理を区切る
// Task.Yield はスレッドプールに制御を返し、続きを再スケジュールする
// 長い同期処理が UI スレッドやスレッドプールを長時間占有するのを防ぐ

public async Task ProcessBatchAsync(IEnumerable<Item> items)
{
    int count = 0;
    foreach (var item in items)
    {
        Process(item);
        if (++count % 100 == 0)
            await Task.Yield(); // 100件ごとにスレッドプールに制御を返す
    }
}

よくある質問

QValueTask<T> と Task<T> はどちらを公開 API に使うべき?
A公開 API には Task<T> を使ってください。ValueTask<T> は「1回しか await できない」「同時に複数箇所で await できない」「WhenAll に直接渡せない」という制約があり、利用者が誤用するリスクがあります。ライブラリ内部で高頻度に同期完了を返す場面(キャッシュヒット等)でのみ ValueTask<T> を使い、外部に公開するメソッドは Task<T> で統一するのが安全です。
QTask.FromResult は new Task とどう違う?
ATask.FromResult(value)即座に完了状態の Task<T> を返します。new Task<T>(func) は開始前(Created 状態)の Task を作り、明示的に .Start() を呼ぶ必要があるため、現代の C# では使うことはほぼありません。Task.RunTask.FromResultasync メソッドが Task を作る正しい方法です。
QWhenAll で1つが例外を投げても他のタスクは動き続けますか?
Aはい。Task.WhenAllすべてのタスクの完了を待ってから例外を集約します。1つが例外を投げても残りは止まりません。ただし await Task.WhenAll(...) では最初の例外のみが再スローされます。全例外を見るには、WhenAll が返した Task の Exception.InnerExceptions を参照するか、個別の task.IsFaulted を確認してください。
Qasync void は Task を返さないメソッドですが、いつ使いますか?
Aasync voidイベントハンドラー専用です(button.Click += async (s, e) => { ... })。それ以外では使わないでください。理由: ① 例外をキャッチできない(AppDomain.UnhandledException に流れてアプリが落ちる)、② 完了を待てない(テストで検証不可)、③ Task.WhenAll で待てない。戻り値が不要でも async Task にして、イベントハンドラーでだけ例外を try-catch で握ってください。
QTask.Delay は何に使いますか?
A非同期的に指定時間待機する API で、主にリトライのバックオフタイムアウトの競争(WhenAny + Task.Delay)テスト用の遅延シミュレーションに使います。Thread.Sleep との最大の違いはスレッドをブロックしない点で、await Task.Delay(1000) は待機中にスレッドを解放して他の処理に回せます。必ず CancellationToken を渡してキャンセル可能にしてください。

まとめ

項目 ポイント
Task vs Task<T> 値を返すなら Task<T>、返さないなら Task
ValueTask<T> 同期完了が多いホットパス限定。公開 API は Task<T>
生成パターン FromResultFromExceptionFromCanceledCompletedTaskRun
TaskCompletionSource コールバック API を Task 化。必ず RunContinuationsAsynchronously をつける
WhenAll / WhenAny 全完了 / 最速1件。型が異なる場合は個別 Task 変数で並行起動
WhenEach .NET 9+。完了順に await foreach
ContinueWith レガシー。新規コードでは await を使う
async eliding 別の Task をそのまま返すだけなら省略可。using / try-catch があれば不可
共変性 Task<Derived>Task<Base> に暗黙変換不可。async メソッド内で await すれば自然に変換
状態検査 IsCompletedSuccessfully 確認後の .Result は安全
.Result / .Wait() 完了前に呼ぶとデッドロック。常に await を優先
IAsyncEnumerable 大量データのストリーミングは Task<List> より効率的

async/await のステートマシンや ConfigureAwait の詳細はasync/await 完全ガイド、非同期と並列の使い分けは非同期処理と並列処理の違い完全ガイド、実装パターン(Fan-out/Rate Limit/Pipeline等)は非同期・並列の実践パターン集、キャンセルの詳細はCancellationToken 完全ガイドを参照してください。