C# async/await実務判断表 - Task.RunとConfigureAwait

· 更新日: · · C#, async/await, .NET, 設計

更新履歴(5件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
ミラーを競争させる例で、呼び出し元のキャンセルをミラーの失敗として数えていたのを直しました。`cancellationToken`が倒れると全タスクが`OperationCanceledException`で終わるため、それを`failures`に積むと最後に`AggregateException`になり、利用者の中断やタイムアウトが「全ミラー障害」として記録・再試行されます。`catch`の先頭で`ThrowIfCancellationRequested()`を呼び、キャンセルはそのまま外へ投げるようにしました。
ミラーを競争させる例で、勝者を確かめる前に残りをキャンセルしていたのを直しました。`Task.WhenAny`が返すのは最初に完了したタスクであって、最初に成功したタスクではありません。いちばん速いミラーが404や接続断で失敗した場合も勝者として返るため、まだ生きている残りのミラーを自分で止めたうえで、失敗した例外を投げ直すことになります。ミラーを複数用意した意味が消える壊れ方です。完了したタスクを1本ずつ取り出して`await`し、成功したときだけ残りをキャンセルする形に直し、全滅したときは個々の失敗をまとめて投げるようにしました。
重複していた7章の判断表を削除し、3.1への参照に置き換えました。ASP.NET Coreについて「スレッドプール上で動くから無意味」としていた記述を、「スループットが増えない」「守るべき特別なスレッドが無い」の2点に整理し直しました。用語表に`IHostedService`と`BackgroundService`を追加し、`cts.Cancel()`をtryの前に置く理由と、目的別の読み分けガイドを追加しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589597)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「C# async/await実務判断表 - Task.RunとConfigureAwait」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589597 https://staging.comcomponent.com/blog/2026/03/09/001-csharp-async-await-best-practices/

DOI(最新版)
10.5281/zenodo.21589597
DOI(この版)
10.5281/zenodo.21732622

C# の async / await は日常的に使いますが、実務で迷いやすいのは構文そのものより、どの場面でどの書き方を選ぶべきか です。 特に検索で多いのは、Task.Run をいつ使うか、ConfigureAwait(false) をどこに付けるか、fire-and-forget を許してよいか、といった判断の悩みです。

  • I/O 待ちなのに Task.Run で包んでしまう
  • 独立した処理なのに 1 件ずつ直列に await してしまう
  • fire-and-forget を安易に入れて例外や終了タイミングを見失う
  • ConfigureAwait(false) をどこでも同じように付けてしまう
  • ValueTask を「軽そうだから」という理由だけで選んでしまう

このあたりは、個別に覚えるより、まず処理の種類を見分ける ところから入るほうが迷いません。

この記事では、主に .NET 6 以降の一般的な C# / .NET アプリ開発 を前提に、 async / await まわりの書き方を 判断しやすい順番 で並べていきます。

想定しているのは、たとえば次のような開発です。

  • WinForms / WPF などのデスクトップアプリ
  • ASP.NET Core の Web アプリ / API
  • worker / バックグラウンドサービス
  • コンソールアプリ
  • 再利用可能なクラスライブラリ

なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(ライブラリ、コンソールデモ、判断表の各パターンを検証するユニットテスト)として GitHub で公開しています。

csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)

この記事の読み方

そこそこ長い記事なので、目的別の入口を先に置いておきます。

目的 読むところ
判断表だけ見たい 3.1 の表と図。この記事の中心はここです
各パターンの書き方を知りたい 3.2 以降。3.1 の表の行と 1 対 1 で対応しています
自分のコードを見直したい 5. のアンチパターン表
レビューの観点をそろえたい 6. のチェックリスト
とにかく結論だけ 1.

目次

  1. まず結論(ひとことで)
  2. この記事で使う言葉
    • 2.1. まず区別したい言葉
    • 2.2. よく出てくる言葉
  3. まず見る判断表
    • 3.1. 全体像
    • 3.2. I/O 待ちなら、async API をそのまま await する
    • 3.3. CPU 負荷が重いなら、Task.Run を使う場所を選ぶ
    • 3.4. 独立した複数処理なら、Task.WhenAll
    • 3.5. 最初に終わったものを使うなら、Task.WhenAny
    • 3.6. 件数が多く、並列数を制限したいなら、Parallel.ForEachAsync か SemaphoreSlim
    • 3.7. 順番に流したいなら、Channel<T>
    • 3.8. 一定間隔で回したいなら、PeriodicTimer
    • 3.9. 逐次的に届くデータなら、IAsyncEnumerable<T>
    • 3.10. 非同期で破棄したいなら、await using
    • 3.11. await をまたぐ排他なら、SemaphoreSlim
    • 3.12. UI / アプリコード / ライブラリで await の書き方を分ける
  4. 書き方の基本ルール
    • 4.1. 返り値はまず Task / Task<T>
    • 4.2. async void はイベントハンドラだけ
    • 4.3. CancellationToken を受けて、下流へ渡す
    • 4.4. 非同期 API は最後まで非同期でつなぐ
    • 4.5. LINQ でタスクを作るときは ToArray / ToList で確定する
  5. よくあるアンチパターン
  6. レビュー時のチェックリスト
  7. ざっくり使い分け
  8. まとめ
  9. 参考資料

この記事の知識マップ

この記事は、C#のasync/awaitで最初に見るべきなのは処理がI/O-boundかCPU-boundかの区別であり、I/O待ちはasync APIをそのままawaitし、CPU計算はUIならTask.Run、ASP.NET Coreのリクエスト処理では避けるべきだと整理します。ConfigureAwait(false)はSynchronizationContextを持たないASP.NET Coreよりも、UIやアプリ固有コンテキストに依存しない汎用ライブラリコードで有力になります。独立した複数のI/O処理はTask.WhenAllやTask.WhenAnyで束ね、件数が多ければParallel.ForEachAsyncやSemaphoreSlimで並列数を制限し、寿命を呼び出し元から切り離したい処理はfire-and-forgetではなくChannelとBackgroundServiceで管理すべきだとしています。

C# async/awaitの実務判断の知識マップI/O-boundかCPU-boundかの見極めがTask.RunやConfigureAwait(false)の使い分け、WhenAll・WhenAny・Channelなどの選択とどうつながるかを示す図です。用いるのは非推奨推奨される対応推奨される対応用いるのは非推奨原因になり得る利用する推奨される対応用いるのは非推奨利用する推奨される対応用いるのは非推奨推奨される対応利用する推奨される対応推奨される対応利用する推奨される対応実装を担う用いるのは非推奨推奨される対応利用する推奨される対応前提とする用いるのは非推奨前提とする推奨される対応I/O-bound処理CPU-bound処理Task.RunUIスレッドのコンテキストASP.NET Coreのリクエスト処理同期待ちの混入(sync-over-async)スレッドプール飢餓(starvation)SynchronizationContextConfigureAwait(false)汎用ライブラリコードCancellationToken(.NET)async voidイベントハンドラメソッド通常の非同期メソッドTask.WhenAllTask.WhenAnyParallel.ForEachAsyncSemaphoreSlimChannelbackpressureBackgroundServiceIHostedServicefire-and-forget呼び出し元から寿命を切り離した背景処理PeriodicTimerIAsyncEnumerableawait usingValueTask

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全26件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

1. まず結論(ひとことで)

  • async / await待機中にスレッドを塞がないための書き方 であって、何でも自動で高速化したり、勝手に別スレッド化したりする仕組みではない
  • まずはその処理が I/O 待ち なのか CPU 計算 なのかを分ける
  • I/O 待ちなら、async API をそのまま await するのが基本
  • CPU 計算なら、どこでその計算を動かすべきか を考える。 UI なら Task.Run が役立つことがあるが、ASP.NET Core のリクエスト処理では Task.Run をすぐ await する書き方は基本的に避ける
  • 独立した複数処理は、直列に await するより Task.WhenAll を先に検討する
  • 件数が多いときは、Task.WhenAll で全部同時に投げるのではなく、並列数の上限 を決める
  • fire-and-forget は簡単に見えて管理が難しい。本当に呼び出し元と寿命を切り離すなら、Channel や HostedService などの管理された場所へ出す ほうが安定する
  • 返り値は まず Task / Task<T>ValueTask は計測して必要性が見えてから選ぶ
  • ConfigureAwait(false)汎用ライブラリコード では有力だが、UI やアプリ側コードではまず普通の await でよい
  • async voidイベントハンドラ以外では使わない

要するに、async / await まわりで一番大事なのは、 「とりあえず Task.Run」「とりあえず fire-and-forget」「とりあえず ValueTask」 にしないことです。

まずは、

  1. その処理は何を待っているのか
  2. 誰がその処理の寿命を持つのか
  3. 同時実行数をどこで制御するのか

この 3 つを見ると、迷いがだいぶ減ります。

2. この記事で使う言葉

2.1. まず区別したい言葉

最初にこの 2 つを分けると、かなり混乱しにくくなります。

言葉 ここでの意味
I/O-bound HTTP、DB、ファイル、ソケットなど、外部の完了待ち が中心の処理
CPU-bound 圧縮、画像処理、ハッシュ計算、重い変換など、CPU 計算そのもの が中心の処理

async / await が特に効くのは I/O 待ちのほうで、待っている間にスレッドを他の仕事へ返せます。一方の CPU 計算は「待ち」ではなく実際に計算している時間なので、どのスレッドで動かすか並列数をどう決めるか が主題になります。

2.2. よく出てくる言葉

言葉 ここでの意味
ブロッキング 完了を待つ間、そのスレッドを占有し続けること
fire-and-forget 呼び出し元が完了を待たない起動方法
SynchronizationContext await の続きをどこで動かすか」を持っている仕組み。詳しくは下の補足を参照
backpressure 流し込みが速すぎるときに、書き込み側を待たせて増えすぎを防ぐ仕組み
IHostedService .NET の汎用ホストが、起動時に StartAsync、停止時に StopAsync を呼んでくれる仕組み。アプリの寿命に合わせて動く常駐処理の入口です
BackgroundService IHostedService を実装した抽象クラス。ExecuteAsync(CancellationToken) を 1 つ override するだけで常駐ループを書けます。AddHostedService<T>() で登録します(3.7)

Channel<T> を使うとき、その消費側(コンシューマ)を置く場所がこの BackgroundService です。3.7 では「キューに積んで専用のコンシューマが順番に処理する」形を扱いますが、そのコンシューマの寿命をアプリの起動・停止に合わせて管理してくれるのがここ、という関係になります。

SynchronizationContext の補足

ConfigureAwait(false) の話(3.12)は、結局この 1 語の理解に集約されます。

  • await は、続きのコード(継続)を実行するとき、待ちに入った時点の SynchronizationContext を捕まえて、そこへ戻して実行しますSynchronizationContext が設定されていない場合は、既定以外の TaskScheduler が使われていないかを見ます)
  • WinForms / WPF は、UI スレッドへ処理を投げ直す SynchronizationContext を持っています。だから await の後で普通にコントロールを触れます
  • ASP.NET Core には SynchronizationContext がありません。そのため「戻る先」がなく、await の続きは空いているスレッドプールのスレッドでそのまま動きます
  • ConfigureAwait(false) は、この捕まえた文脈へ戻さずに続きを実行してよい、という指定です

ここから、「UI コードでは付けない方が自然」「ASP.NET Core のアプリコードでは付けても付けなくても大差ない」「どちらで動かされるか分からない汎用ライブラリでは付ける価値がある」という 3.12 の結論が出てきます。詳しい背景は 9. 参考資料の ConfigureAwait FAQ が最もまとまっています。

特に大事なのは、非同期と並列は別物 という点です。

  • 非同期: 待ち方の話
  • 並列: 同時に進める話

この 2 つが混ざると、Task.Run をどこでも使いたくなります。 ここが最初の分かれ道です。

3. まず見る判断表

3.1. 全体像

まずはこの表から見ると、だいたいの方針が決まります。

状況 まず使うもの 見るポイント
HTTP / DB / ファイルなどの待ち async API をそのまま await Task.Run で包まない
UI を止めたくない重い計算 Task.Run CPU 計算を UI スレッドから外す
ASP.NET Core のリクエスト処理 plain await Task.Run をすぐ await しない
独立した少数の非同期処理 Task.WhenAll 先に全部開始してからまとめて待つ
最初に終わったものだけ使う Task.WhenAny 残りのキャンセルや例外回収を考える
件数が多く、上限を付けたい Parallel.ForEachAsync / SemaphoreSlim 並列数を明示する
順番に流したいバックグラウンド処理 Channel<T> 境界付きキューと backpressure を考える
一定間隔の非同期処理 PeriodicTimer 1 タイマー 1 コンシューマを守る
結果を少しずつ処理したい IAsyncEnumerable<T> / await foreach 全件完了を待たずに進める
非同期破棄が必要 await using IAsyncDisposable を使う
await をまたぐ排他 SemaphoreSlim.WaitAsync try/finally で必ず Release
汎用ライブラリコード ConfigureAwait(false) を検討 UI / アプリ固有コンテキストに依存しない
はいいいえはいUI イベント / デスクトップASP.NET Core のリクエストworker / バックグラウンドいいえ全部終わるまで待つ最初に終わったものを使う件数が多い順番に流す一定間隔逐次ストリームやりたい処理外部 I/O を待つ?async API をそのまま await するCPU 計算が重い?どこで動かす?Task.Run を検討するTask.Run で包まない必要なら別ワーカーやキューへ出すその場で実行するか並列度を明示する複数の仕事を扱う?Task.WhenAllTask.WhenAnyParallel.ForEachAsyncまたは SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

以下、各パターンを順番に見ていきます。

3.2. I/O 待ちなら、async API をそのまま await する

一番基本になるパターンです。

たとえば、HTTP、DB、ファイル読み書きなどは、まず async 版 API があるか を見ます。 あるなら、それをそのまま await するのが基本です。

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

このとき避けたいのは、すでに async な I/O を Task.Run で包むことです。

// 良くない例
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

これは、I/O 待ちを別スレッドへ投げ直しているだけで、整理しづらくなるわりに得がありません。

  • I/O 待ちなら Task.Run はいらない
  • まず async API を探す
  • token を受けたら、そのまま下流へ渡す

ここはかなり王道です。

3.3. CPU 負荷が重いなら、Task.Run を使う場所を選ぶ

Task.Run が効くのは、CPU 計算を今のスレッドから外したいとき です。

たとえば UI イベントハンドラで重い計算をそのまま回すと、画面が止まります。 こういうときは Task.Run が素直です。

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

ただし、ここで大事なのは どこで呼んでいるか です。

  • WinForms / WPF などの UI: Task.Run が有効な場面がある
  • ASP.NET Core のリクエスト処理: Task.Run をすぐ await する書き方は基本的に避ける
  • worker / バックグラウンド処理: その場で処理するか、並列度を設計する

ASP.NET Core のリクエスト処理に Task.Run を 1 枚挟んで直後に await しても、余計なスケジューリングが増えるだけになりやすいです。

ここは誤解しやすいところなので、理由を分けて書いておきます。「スレッドプール上で動いているから Task.Run は無意味」ではありません(スレッドプール上で動くこと自体は、UI アプリのバックグラウンド処理でも同じです)。要点は次の 2 つです。

  • スループットは増えません。 CPU 計算の総量は変わらず、動かす場所が別のスレッドプールのスレッドへ移るだけです。同時に処理できるリクエスト数が増えるわけではありません
  • 待機の解放にもなりません。 UI アプリで Task.Run が効くのは、空けるべき特別なスレッドが 1 本ある(UI スレッド)からです。サーバー側にはその 1 本がありません。元のスレッドはたしかに解放されますが、代わりに別のスレッドが同じ時間だけ計算で埋まるので、差し引きゼロです

残るのは、キューへの投入とスレッド切り替えのコスト、そして「どのスレッドで動いているか」が 1 段分かりにくくなることです。だから避けます。

なので、ASP.NET Core ではこう考えるほうが筋がよいです。

  • I/O 待ちなら plain await
  • 短い CPU 処理なら、その場で実行
  • 長い処理やリクエスト寿命から切り離したい処理なら、キューや HostedService に出す

なお、sync 版しかない API を UI から呼ぶ場合、UI 応答性のために Task.Run を使うことはあります。 ただしこれは「非同期 I/O」ではなく、スレッド 1 本を占有して回避している だけです。 ASP.NET Core のようなサーバー側では、この逃がし方は基本的に伸びにくいです。

3.4. 独立した複数処理なら、Task.WhenAll

独立した非同期処理が複数あるのに、こんなふうに 1 件ずつ待つコードはよく出てきます。

// 独立しているのに直列になっている例
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

これらが互いに依存していないなら、先に全部開始して、最後にまとめて待つ ほうが素直です。

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

ポイントは ToArray() です。 LINQ は遅延実行なので、Select しただけでは、まだ列挙されていないことがあります。 ToArray()ToList() でいったん確定しておくと、全部のタスクがその時点で開始されます。

Task 3Task 2Task 1呼び出し元Task 3Task 2Task 1呼び出し元開始開始開始await Task.WhenAll(...)完了完了完了

このパターンが向いているのは、

  • 件数が少ない、または中程度
  • 全件をまとめて待ちたい
  • 上限なしで同時に走らせても問題ない

という場合です。

件数が多いなら、次の 3.6 のように 並列数の上限 を付けたほうが安全です。

3.5. 最初に終わったものを使うなら、Task.WhenAny

たとえば、複数のミラー先のうち最初に応答したものを使いたい、という場面では Task.WhenAny が分かりやすいです。

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    List<Task<byte[]>> pending = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToList();

    var failures = new List<Exception>();

    try
    {
        while (pending.Count > 0)
        {
            Task<byte[]> finished = await Task.WhenAny(pending);
            pending.Remove(finished);

            try
            {
                byte[] data = await finished;   // 成功したときだけ、ここを抜ける
                cts.Cancel();                   // 勝者が決まってから残りを止める
                return data;
            }
            catch (Exception ex)
            {
                // 呼び出し元が止めたのなら、それは「ミラーの失敗」ではない。
                // ここを素通りさせると、全タスクのキャンセルが失敗として溜まり、
                // 最後に AggregateException になって障害と見分けが付かなくなる
                cancellationToken.ThrowIfCancellationRequested();

                // このミラーは駄目だった。残りにまだ望みがあるので続ける
                failures.Add(ex);
            }
        }
    }
    finally
    {
        cts.Cancel();   // 例外で抜けたときも、残っているダウンロードは止める

        try
        {
            await Task.WhenAll(pending);
        }
        catch
        {
            // 勝者以外のキャンセルや失敗を回収する
        }
    }

    throw new AggregateException("すべてのミラーからの取得に失敗しました。", failures);
}

このコードで順序に意味があるのは、キャンセルを「勝者が決まってから」出している点です。

  • Task.WhenAny が返すのは 最初に完了したタスクであって、最初に成功したタスクではありません。いちばん速いミラーが 404 や接続断で失敗したときも、それが「勝者」として返ってきます
  • ここで結果を見る前にキャンセルすると、まだ生きている残りのミラーを自分で止めたうえで、失敗した勝者の例外を投げ直すことになります。ミラーを複数用意した意味がまるごと消える、いちばんまずい壊れ方です
  • なので、完了したタスクを1本ずつ取り出して await し、成功したときだけ残りをキャンセルします。失敗なら、そのタスクを候補から外して次の完了を待ちます
  • Cancel()要求を出すだけ で、相手が止まるまで待ちません。だから finally で残りを待ち、キャンセルや失敗の例外をここで観測しています。これを省くと、誰も見ていない例外がタスク側に残ります
  • 全部失敗したときは、個々の失敗をまとめて投げます。1本目の例外だけ投げると、「どのミラーがどう駄目だったか」が消えます
  • 呼び出し元のキャンセルだけは、失敗として数えずにそのまま外へ投げます。cancellationToken が倒れると全タスクが OperationCanceledException で終わるので、これを failures に積むと最後に AggregateException になり、利用者の中断やタイムアウトが「全ミラー障害」として記録・再試行されます。catch の先頭で ThrowIfCancellationRequested() を呼び、キャンセルは OperationCanceledException のまま返します

ここで気を付けたいのは、WhenAny勝者を 1 つ返すだけ という点です。 残りの処理は、何もしなければそのまま動き続けます。

なので、

  • 残りはキャンセルしたいのか
  • 例外は観測しておきたいのか

を先に決めておく必要があります。

Task.WhenAny は便利ですが、WhenAll より少し設計が増えます。 「最初の 1 つだけでよい」場合にだけ選ぶと分かりやすいです。

3.6. 件数が多く、並列数を制限したいなら、Parallel.ForEachAsync か SemaphoreSlim

Task.WhenAll は、作ったタスクを全部同時に走らせます。 なので、対象件数が多いと、HTTP 接続、DB 接続、メモリ使用量、外部サービスへの負荷が一気に増えます。

こういうときは、同時に何件まで動かすか を決めたほうが安定します。

Parallel.ForEachAsync は、その意図がかなり読みやすいです。

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

このパターンが向いているのは、

  • 件数が多い
  • 各項目の処理は独立している
  • ただし全件一斉は避けたい

という場合です。

一方で、もっと自由に制御したいなら SemaphoreSlim を使う方法もあります。 たとえば「特定の外部 API には同時に 4 件まで」みたいな制御です。

つまり、

  • 数件なら Task.WhenAll
  • 大量件数なら Parallel.ForEachAsyncSemaphoreSlim

という使い分けで、大きく外すことはありません。

3.7. 順番に流したいなら、Channel<T>

「今すぐ終わらなくてよいが、確実に処理したい」仕事を、呼び出し元から切り離したくなることがあります。 メール送信、ログの転送、Webhook の後処理、ファイル変換などです。

こういうときに Task.Run を投げっぱなしにすると、

  • 例外をどこで見るのか
  • 終了時に待つのか
  • 件数が増えたときにどこまで受けるのか

が曖昧になります。

この種の仕事は、キューに積んで、専用のコンシューマが順番に処理する ほうが管理しやすいです。

はいいいえproducerWriteAsyncキューに空きがある?Channel に入る空くまで待つconsumer が ReadAsync順番に await して処理

Channel<T> は、producer / consumer の形をかなり素直に書けます。

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

この例での BoundedChannelFullMode.Wait は、キューが満杯なら書き込み側を待たせる という設定です。 これが backpressure です。

ASP.NET Core なら、こうしたキューを BackgroundService と組み合わせて消費する形が分かりやすいです。 「本当の fire-and-forget」より、こちらのほうが例外、停止、並列数、上限を扱いやすくなります。

3.8. 一定間隔で回したいなら、PeriodicTimer

一定間隔の非同期処理なら、PeriodicTimer はかなり読みやすいです。

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

この書き方のよいところは、

  • コールバック型の Timer より流れが追いやすい
  • await ベースで書ける
  • 停止時に CancellationToken を素直に使える

という点です。

注意点として、PeriodicTimer1 つのタイマーに対して、同時に複数の WaitForNextTickAsync を飛ばさない 前提で使います。 また、処理時間が周期より長ければ、その遅れは設計として扱う必要があります。 タイマーが勝手に並列化して追いついてくれるわけではありません。

3.9. 逐次的に届くデータなら、IAsyncEnumerable<T>

全件を List<T> にためてから返すより、届いたものから順に処理したい 場面があります。

  • ページングされた API を順に読む
  • ファイルの行を少しずつ読む
  • ストリーミング結果をそのまま流す

こういうときは IAsyncEnumerable<T>await foreach が自然です。

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

この形が向いているのは、

  • 全件そろうまで待ちたくない
  • 1 件ずつ処理していきたい
  • メモリに全部ためたくない

という場合です。

戻り値を Task<List<T>> にするか IAsyncEnumerable<T> にするかは、 結果を全部そろえてから使うのか、届いた順に使うのか で決めると分かりやすいです。

3.10. 非同期で破棄したいなら、await using

破棄時にフラッシュや通信終了などの非同期処理が必要な型は、IAsyncDisposable を実装します。 その場合は using ではなく await using を使います。

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

ポイントは、

  • IAsyncDisposable なら await using
  • 「開く」は同期でも、「閉じる」が非同期ということは普通にある

ということです。

「書き込みは async にしたのに、最後の破棄だけ同期」というずれを避けたいときに効きます。

3.11. await をまたぐ排他なら、SemaphoreSlim

await をまたぐコードでは、lock の代わりに SemaphoreSlim を使う場面があります。

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

大事なのは、

  • WaitAsync で入る
  • Releasefinally で必ず呼ぶ

という 2 点です。

「同時に 1 件だけ入れたい」「外部 API 呼び出しを同時に 3 件までにしたい」 という場面では、SemaphoreSlim はかなり実用的です。

3.12. UI / アプリコード / ライブラリで await の書き方を分ける

ConfigureAwait(false) は、いつでも付ければよい、というものではありません。

大まかな分け方はこうです。

UI / アプリコードawait someAsync()元のコンテキストに戻って続行汎用ライブラリawait someAsync().ConfigureAwait(false)特定コンテキストに戻る前提を持たない
  • UI / アプリコード
    • まず普通の await でよいです
    • await 後に UI 更新やアプリ側コンテキスト依存の処理をするなら、ConfigureAwait(false) は付けないほうが自然です
  • ASP.NET Core のアプリコード
    • 通常は普通の await で十分です
    • ConfigureAwait(false) を全体の作法として無理に徹底しなくてもよいです
  • 汎用ライブラリコード
    • UI やアプリモデルに依存しないなら、ConfigureAwait(false) は有力です

つまり、

  • アプリ側コードは plain await
  • 汎用ライブラリは ConfigureAwait(false) を検討

と覚えておけば、実務ではまず困りません。

4. 書き方の基本ルール

4.1. 返り値はまず Task / Task<T>

async メソッドの返り値は、まずこの順で考えます。

返り値 まずの考え方
Task 戻り値なしの async メソッドの基本
Task<T> 値を返す async メソッドの基本
ValueTask / ValueTask<T> 計測して必要性が見えてから選ぶ

ValueTask は便利そうに見えますが、常に Task よりよいわけではありません。 構造体なのでコピーコストがあり、使い方にも制約があります。

特に大事なのは、ValueTask は基本的に 1 回だけ await する前提 という点です。 安易にローカル変数へ保持して何度も待つような書き方には向きません。

なので、日常的なアプリコードでは、まず Task / Task<T> で十分です。

また、メソッド名には Async サフィックス を付けるほうが分かりやすいです。

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

上のように、await する処理がないなら、無理に async を付けずに Task.CompletedTaskTask.FromResult を返すほうが素直です。

4.2. async void はイベントハンドラだけ

async void は、イベントハンドラ以外では避けるのが基本です。

理由はシンプルで、

  • 呼び出し元が await できない
  • 完了を待てない
  • 例外処理が難しくなる
  • テストしづらい

からです。

イベントハンドラだけは void が必要なので、そこでだけ使います。

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "保存しました。";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "キャンセルしました。";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "保存エラー");
    }
}

イベントハンドラでは、中で例外を握って UI 側へ返す ところまで自分で書く、という意識が大事です。

4.3. CancellationToken を受けて、下流へ渡す

キャンセル可能な操作なら、CancellationToken を受けて、そのまま下流へ渡します。

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

ここでよくあるのは、上位では token を受けているのに、下流へ渡していないパターンです。 これだと「キャンセルできるように見えて、途中で止まらない」コードになりがちです。

また、タイムアウト も「待機だけに上限を付けたい」のか、「実処理自体も止めたい」のかで意味が変わります。

  • 待機だけに上限を付けたい: WaitAsync
  • 実処理自体も止めたい: CancellationTokenSource.CancelAfter と token の伝播

この違いは、後から不具合になりやすいので最初に決めておくと安定します。

4.4. 非同期 API は最後まで非同期でつなぐ

async / await を使うなら、できるだけ 最後まで非同期でつなぐ ほうが素直です。

置き換えの目安は次のとおりです。

したくなる書き方 置き換え先
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

特に UI や ASP.NET Core では、同期的に待つ書き方 が混ざると、詰まり方が読みにくくなります。

今の C# では async Task Main() も使えるので、コンソールアプリでも無理に同期化する理由はかなり減っています。

4.5. LINQ でタスクを作るときは ToArray / ToList で確定する

Task.WhenAllTask.WhenAny と LINQ を組み合わせるときは、 ToArray()ToList() でいったん確定するほうが安全です。

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

理由は、LINQ が遅延実行だからです。 「もう全部始まっているつもり」で読んでいたら、実はまだ列挙されていない、というのは地味に危険です。

  • 全件まとめて待つなら ToArray()
  • 途中で削除・入れ替えしたいなら ToList()

と覚えておくと使い分けやすいです。

5. よくあるアンチパターン

アンチパターン 何がつらいか まずの置き換え
Task.Run(async () => await IoAsync()) I/O 待ちを無駄に投げ直している await IoAsync()
Task.Result / Wait() スレッドを塞ぐ。詰まりやすい await
Thread.Sleep() を async フローに混ぜる 待機中もスレッドを占有する Task.Delay()
async void を普通のメソッドで使う 待てない、例外管理しづらい Task / Task<T>
Task.WhenAll すべき場面で直列 await 不必要に遅くなる 先に全部開始して WhenAll
大量件数を WhenAll で一気に投げる 負荷が跳ねる Parallel.ForEachAsync / SemaphoreSlim
lock で await をまたごうとする 目的に合わない SemaphoreSlim.WaitAsync
fire-and-forget を素の Task.Run で済ませる 例外・停止・上限の管理が曖昧 Channel<T> / BackgroundService
ConfigureAwait(false) を UI コードへ機械的に付ける await 後の UI 更新が壊れやすい plain await
ValueTask を標準にする 複雑さのわりに得が出ないことが多い まず Task

この表の中で、特に実務でよく見かけるのは次の 3 つです。

  1. I/O なのに Task.Run
  2. 本当は独立なのに直列 await
  3. fire-and-forget の寿命管理がない

この 3 つを直すだけでも、コードの見通しはかなり改善します。

6. レビュー時のチェックリスト

async / await まわりのコードレビューでは、上から順にこのあたりを確認していきます。

  • その処理は I/O-boundCPU-bound かを、最初に言葉で説明できるか
  • Task.Result / Task.Wait() / Thread.Sleep() が残っていないか
  • I/O 待ちを Task.Run で包んでいないか
  • 独立した処理を、不要に直列 await していないか
  • 逆に、大量件数を無制限に WhenAll していないか
  • CancellationToken を受けているなら、下流へちゃんと渡しているか
  • async void がイベントハンドラ以外にないか
  • fire-and-forget を入れているなら、例外・停止・上限を誰が管理するか決まっているか
  • SemaphoreSlim を使っているなら、Releasefinally に入っているか
  • ValueTask を使っているなら、計測上の理由があるか、1 回 await 前提になっているか
  • ConfigureAwait(false) の有無が、そのコードの種類と合っているか
    • UI / アプリコードなら plain await
    • 汎用ライブラリなら ConfigureAwait(false) を検討

このチェックリストは、チームでレビュー観点をそろえるのにも使いやすいです。

7. ざっくり使い分け

使い分けの一覧は 3.1 の判断表 に集約しています。同じ表を再掲するより、必要になったときにそこへ戻る方が探しやすいので、ここでは表を置きません。

  • 状況別に「まず何を使うか」を見たい → 3.1 の判断表
  • 各パターンの書き方を見たい → 3.2 〜 3.12(3.1 の表の行と対応しています)

3.1 の表に入っていない判断が 1 つだけあります。返り値の型 です。ここは状況ではなくメソッドの設計の話なので、4.1 にまとめてあります。結論だけ書くと、まず Task / Task<T> を選び、ValueTask は計測して必要性が見えてから、です。

8. まとめ

async / await のベストプラクティスは、細かいテクニックを何個も覚えるというより、 処理の種類に合わせて型を選ぶ という整理のほうが実務では効きます。

見る順番は、だいたいこうなります。

  1. I/O 待ちか CPU 計算かを分ける
  2. I/O なら async API をそのまま await する
  3. CPU 計算なら、どこで動かすべきかを決める
  4. 複数処理なら WhenAll / WhenAny / 並列数制限を選ぶ
  5. リクエスト寿命から外すなら、素の fire-and-forget ではなくキュー化する
  6. 返り値、キャンセル、例外、排他、コンテキストの扱いをそろえる

async / await は書き方そのものが簡潔なぶん、雑に使うと方針が見えにくくなります。逆に、

  • I/O は I/O として扱う
  • CPU は CPU として扱う
  • 背景処理は背景処理として寿命を管理する

この 3 つを分けるだけで、かなり読みやすくなります。

9. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

C#でTask.Runはいつ使うべきですか?
Task.Run が効くのは、CPU 計算を今のスレッドから外したいときです。たとえば WinForms / WPF の UI イベントハンドラで重い計算をそのまま回すと画面が止まるため、Task.Run で UI スレッドから外すのが素直です。一方、ASP.NET Core のリクエスト処理はもともと ThreadPool 上で動いているので、Task.Run を挟んで直後に await しても余計なスケジューリングが増えるだけになりやすく、基本的に避けます。長い処理やリクエスト寿命から切り離したい処理は、キューや HostedService に出すほうが筋がよいです。
I/O処理をawait Task.Run()で包んではいけないのですか?
HTTP、DB、ファイル読み書きなどの I/O 待ちは、async 版 API をそのまま await するのが基本で、Task.Run で包む必要はありません。すでに async な I/O を Task.Run で包むのは、I/O 待ちを別スレッドへ投げ直しているだけで、整理しづらくなるわりに得がありません。なお sync 版しかない API を UI から呼ぶ場合に応答性のため Task.Run を使うことはありますが、これは非同期 I/O ではなくスレッド1本を占有して回避しているだけなので、サーバー側では伸びにくい逃がし方です。
ConfigureAwait(false)はどこに付けるべきですか?
UI やアプリ側のコードでは、まず普通の await でよいです。await 後に UI 更新やアプリ側コンテキストに依存する処理をするなら、ConfigureAwait(false) は付けないほうが自然です。ASP.NET Core のアプリコードも通常は普通の await で十分で、作法として無理に徹底する必要はありません。ConfigureAwait(false) が有力なのは、UI やアプリモデルに依存しない汎用ライブラリコードです。「アプリ側は plain await、汎用ライブラリは ConfigureAwait(false) を検討」と覚えておけば実務ではまず困りません。
async voidはなぜイベントハンドラ以外で避けるべきですか?
async void は呼び出し元が await できず、完了を待てず、例外処理が難しくなり、テストもしづらいからです。通常のメソッドは Task または Task<T> を返すのが基本です。イベントハンドラだけはシグネチャ上 void が必要なのでそこでだけ使い、その場合はハンドラの中で try/catch により例外を握って UI 側へ返すところまで自分で書く意識が大事です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る