CancellationToken×Task.WhenAny徹底解説:キャンセル時のメッセージ分岐と実装ベストプラクティス

非同期のキャンセル処理で「いつ」「どのメッセージを出すか」は、CancellationTokenの“協調的”な性質と、Task.WhenAnyが返すタスクの順序で決まります。本記事では、Enter キーでキャンセルする典型的なサンプルを題材に、finishedTask == cancelTask判定とTaskCanceledExceptionの関係を、コード・ログ・再現手順・設計指針まで徹底解説します。

目次

前提:扱うサンプルの構図

ここで扱う構図は次のとおりです。

  • sumPageSizesTask:ダウンロード(または時間のかかる処理)を行う Task。
  • cancelTask:ユーザーの Enter 押下を待つ Task。
  • finishedTask = await Task.WhenAny(cancelTask, sumPageSizesTask) で「先に終わった方」を取得。
  • if (finishedTask == cancelTask) の内側で cts.Cancel() を呼び、sumPageSizesTask の結果(成功 or キャンセル例外)を観測してメッセージを出す。

結論の要約(先に知りたい方向け)

最終的にコンソールへ表示されるメッセージは、次の条件表のとおりです。

ケース終了順序(代表パターン)finishedTask表示されるメッセージ動作のポイント
① キャンセル要求が先 + 処理は途中cancelTask → sumPageSizesTask(キャンセル例外)cancelTask“Download task has been cancelled.”cts.Cancel() 後、処理側がトークンを観測して TaskCanceledException(または OperationCanceledException)を送出。
② キャンセル要求が先 + 処理ほぼ完了cancelTask → すぐ sumPageSizesTask(正常完了)cancelTask“Download task completed before cancel request was processed.”キャンセル要求はあったが、キャンセルを “処理側が観測する前に” 仕事が終わった。
③ 処理が先に完了sumPageSizesTask(正常 or キャンセル)sumPageSizesTask追加メッセージなし(通常は結果表示 or 終了)finishedTask == cancelTask が偽なので “2つのメッセージ” は出ない。
微妙なタイミング差により、sumPageSizesTask が キャンセル完了 の方が cancelTask の完了通知より早いこともある。

要点は「キャンセルは割り込みではなく “協調”」であり、「どちらが先に 完了状態 に遷移して WhenAny に観測されるか」で結果が変わる、という点です。

Task.WhenAny と finishedTask == cancelTask 判定の本質

  • Task.WhenAny(a, b) は、完了状態(RanToCompletion / Faulted / Canceled) に最初に遷移したタスクを返します。
  • 返ってきた参照を finishedTask に保持し、if (finishedTask == cancelTask) で「Enter 押下(キャンセル起点)を先に検出したか」を判定します。
  • ただし、cancelTask が Enter 押下を契機に Cancel を呼ぶ前提 でも、スケジューラの都合で sumPageSizesTask のキャンセル完了が先に WhenAny に観測されることがあります(この時は finishedTask != cancelTask になる)。

「キャンセル要求」→「キャンセル例外」のズレが生む2つのメッセージ

サンプルでは if (finishedTask == cancelTask) ブロック内で cts.Cancel() を呼び、その直後に sumPageSizesTask を await して状態を「観測」します。結果は次の二択です。

  1. 観測時点で未完了 → その後 TaskCanceledException が投げられ、キャンセル発生 メッセージ。
  2. 観測時点で既に完了 → 例外は出ず、キャンセル要求が処理される前に終わっていた メッセージ。

このデザインにより、Enter を押すタイミングが完了直前だと、ほぼ同時に見える “競合” をメッセージで説明できるようになっています。

再現しやすい最小コード(ログ付き)

次のコードは、Task.Status とタイムスタンプを出しながら、3つのケースを狙って再現できます。
Enter を押すタイミングや delayPerUrlMs を調整し、挙動の違いを観察してみてください。


// > .NET 6 以降想定
using System.Diagnostics;

static async Task Main()
{
    var urls = new[]
    {
        "https://example.org/a",
        "https://example.org/b",
        "https://example.org/c"
    };

    using var cts = new CancellationTokenSource();

    // ダウンロードの代わりに「協調キャンセル対応の遅延」を使って再現性を高める
    Task<int> sumPageSizesTask = SumWorkAsync(urls, delayPerUrlMs: 1500, cts.Token);

    // Enter 待ちタスク
    Task cancelTask = Task.Run(() =>
    {
        Console.WriteLine(ts("Press <Enter> to cancel..."));
        Console.ReadLine();
        Console.WriteLine(ts("[Input] Enter pressed"));
    });

    Task finishedTask = await Task.WhenAny(cancelTask, sumPageSizesTask);
    Console.WriteLine(ts($"finishedTask == cancelTask ? {ReferenceEquals(finishedTask, cancelTask)}"));
    Dump("cancelTask", cancelTask);
    Dump("sumPageSizesTask", sumPageSizesTask);

    if (finishedTask == cancelTask)
    {
        // ここで初めてキャンセル要求を出すデザイン
        cts.Cancel();
        Console.WriteLine(ts("[CTS] Cancel() called"));

        try
        {
            int total = await sumPageSizesTask; // 成功 or TaskCanceledException
            Console.WriteLine(ts($"[Result] Completed before cancel processed. total={total}"));
            Console.WriteLine("Download task completed before cancel request was processed.");
        }
        catch (TaskCanceledException)
        {
            Console.WriteLine(ts("[Result] Observed TaskCanceledException"));
            Console.WriteLine("Download task has been cancelled.");
        }
    }
    else
    {
        int total = await sumPageSizesTask; // ここに来る時は通常成功
        Console.WriteLine(ts($"[Result] sum={total}"));
    }

    Console.WriteLine(ts("Application ending."));
}

static async Task<int> SumWorkAsync(string[] urls, int delayPerUrlMs, CancellationToken token)
{
    int sum = 0;
    for (int i = 0; i < urls.Length; i++)
    {
        // 「協調的なキャンセル」を模す
        await Task.Delay(delayPerUrlMs, token);
        token.ThrowIfCancellationRequested();
        sum += 1000 + i; // ダミーのサイズ
        Console.WriteLine(ts($"[Work] {urls[i]} done"));
    }
    return sum;
}

static void Dump(string name, Task t)
{
    Console.WriteLine(ts($"{name}: Id={t.Id}, Status={t.Status}"));
}

static string ts(string msg) => $"[{Stopwatch.GetTimestamp()}] {msg}";

使い方のヒント:

  • Enter を早めに押す → ケース①になりやすい(キャンセル例外)。
  • Enter を最後の Task.Delay 直前〜直後に押す → ケース②が再現しやすい(まれに成功メッセージ)。
  • delayPerUrlMs を短くするとケース②の再現がさらに容易。

ケースごとのタイムライン


ケース①(キャンセル要求が先/処理はまだ進行中)
User: Enter
cancelTask: RanToCompletion(WhenAnyが検出)
if == cancelTask → cts.Cancel()
sumPageSizesTask: ... トークン観測 → Canceled
await sumPageSizesTask → TaskCanceledException → "has been cancelled."

ケース②(キャンセル要求はあったが処理が先に完了)
User: Enter
cancelTask: RanToCompletion(WhenAnyが検出)
if == cancelTask → cts.Cancel()
sumPageSizesTask: すでに RanToCompletion(または直後に完了)
await sumPageSizesTask → 例外なし → "completed before cancel processed."

ケース③(処理が先に完了)
sumPageSizesTask: RanToCompletion(または Canceled が先に確定)
WhenAny は sumPageSizesTask を返す → if 偽
→ 2つのメッセージは出ない(通常は結果を出力して終了) 

「キャンセルは協調的」の意味と実務的含意

  • 協調的: Cancel() は「要求」を出すだけ。実際に止まるかは処理側が token をチェック・例外送出するかに依存。
  • I/O と計算で挙動が異なる: HttpClient.GetAsync(..., token) のように API 自体がトークン対応なら、内部でキャンセル を検知して例外を投げます。計算ループなら自分で ThrowIfCancellationRequested() を入れないと止まりません。
  • 観測のタイミング: 例外は “発生” しただけでは伝播せず、await 等で「観測」された瞬間にコードフローが変わります。

TaskCanceledException と OperationCanceledException の違い

項目TaskCanceledExceptionOperationCanceledException
継承OperationCanceledException を継承Exception を継承するキャンセル用基本型
代表的な発生源await した キャンセル完了済み の Task処理内の token.ThrowIfCancellationRequested() など
ハンドリング推奨catch (OperationCanceledException oce) when (oce.CancellationToken == token) の ガード付き が最も安全。

「.NET のバージョンや環境」で変わる微妙な順序

同じロジックでも、OS・CPU・スレッドプールの状態・.NET のバージョンによって、cancelTask と sumPageSizesTask の「どちらが 先に完了として観測されるか」が入れ替わることがあります。

  • .NET(モダンランタイム)では: Enter 押下後、cancelTask が 先に 完了として観測され、ケース①が出やすい。
  • 一部の環境では: Enter 押下の直後に sumPageSizesTask が より早く Canceled / RanToCompletion に遷移し、WhenAny がそちらを返す(ケース③)。

いずれも正しい挙動です。肝心なのは「非決定的な順序に依存した仕様 にしない」ことです。

ログで “いま何が起きたか” を可視化する

デバッグでは、if 判定直前に次のログを入れると状況が一目で分かります。


Console.WriteLine($"cancelTask  : {cancelTask.Status} (Id={cancelTask.Id})");
Console.WriteLine($"sumPageTask : {sumPageSizesTask.Status} (Id={sumPageSizesTask.Id})");
Console.WriteLine($"finished    : {finishedTask.Status} (Id={finishedTask.Id})");

競合を “仕様” として吸収する設計パターン

パターンA:キャンセル要求は「即座に」伝播させる

cancelTask の完了を待ってから cts.Cancel() するのではなく、押下直後に伝播します。


// Enter を検出したら即座に Cancel
_ = Task.Run(() => { Console.ReadLine(); cts.Cancel(); });

var first = await Task.WhenAny(sumPageSizesTask, Task.Delay(Timeout.Infinite, cts.Token));
if (first == sumPageSizesTask)
{
// 結果(成功/例外)をそのまま観測
}
else
{
// ここに来るのは「キャンセル要求が先に観測された」場合のみ
try { await sumPageSizesTask; /* ②の文言 */ }
catch (OperationCanceledException) { /* ①の文言 */ }
} 

利点:「キャンセル要求が先か」の判定が明確で、ケース②/①の説明がぶれません。

パターンB:トークンを Task に変換して待つ(拡張メソッド)


public static class CancellationTokenExtensions
{
    public static Task AsTask(this CancellationToken token)
        => Task.Delay(Timeout.InfiniteTimeSpan, token);
}

// 使い方
var cancelSignal = cts.Token.AsTask();
var first = await Task.WhenAny(sumPageSizesTask, cancelSignal); 

この方法なら WhenAny の競合対象が「処理 Task vs キャンセルシグナル Task」になり、観測点 が明瞭になります。

パターンC:CancelAsync() の活用

多数の token.Register がある場合、Cancel() は同期的にコールバックを実行して呼び出し元がブロックされることがあります。UI などでは CancelAsync() を検討するとスムーズです(.NET の近年版で利用可能)。

よくある落とし穴と回避策

落とし穴何が起きるか回避策
計算ループでトークンを見ないキャンセルしてもいつまでも止まらないThrowIfCancellationRequested()/ループ粒度で IsCancellationRequested をチェック
キャンセル例外を握りつぶす外側がキャンセルを把握できず、後始末や UI が不整合catch (OperationCanceledException) で必ず分岐し、状態を更新/ログに残す
WhenAny の戻り Task を参照しないどちらが先に終わったか分からず、二重待機やリークReferenceEquals(finishedTask, cancelTask) で必ず分岐
HttpClient の使い回し不適切接続リソースの枯渇やパフォーマンス低下長期間使い回す/IHttpClientFactory を利用(アプリ種別に応じて)

“安定して読みやすい” メッセージ設計のテンプレート

ユーザーに優しいメッセージのための、分岐テンプレートを提示します。


var first = await Task.WhenAny(workTask, cts.Token.AsTask());

if (first == workTask)
{
try
{
var result = await workTask; // 正常 or Faulted(キャンセル以外の例外)
Console.WriteLine("Download completed successfully.");
}
catch (OperationCanceledException oce) when (oce.CancellationToken == cts.Token)
{
Console.WriteLine("Download was cancelled."); // 競合で workTask が先に Canceled になった場合
}
catch (Exception ex)
{
Console.WriteLine($"Download failed: {ex.Message}");
}
}
else
{
// ここに来るのは「確実にキャンセル要求が先」のみ
try
{
await workTask; // ケース②ならここは成功で抜ける
Console.WriteLine("Download completed before cancel request was processed.");
}
catch (OperationCanceledException)
{
Console.WriteLine("Download task has been cancelled.");
}
} 

検証のためのチェックリスト

  • キャンセル経路は 処理を中断させる 経路と 完了を見届ける 経路の両方を持つか?
  • キャンセル例外は 適切な層 で捕捉し、上位に 語彙(Cancelled/Completed/Failed) を還元しているか?
  • WhenAny 後の分岐が「非決定的な順序」に依存していないか?
  • ログに Task.Id と Task.Status を残し、後から再現できるか?
  • UI のボタン多連打・タイムアウト・ネットワーク再試行など、現実的な競合を想定しているか?

FAQ

Q. ケース②はどのくらいの頻度で起こる? A. 通常は稀です。I/O が既に完了寸前で、Cancel() を呼ぶ前に仕事が終わった場合に現れます。再現したいなら処理末尾に軽い遅延を入れて観測すると理解が進みます。 Q. そもそも “2種類のメッセージ” は必要? A. ユーザー体験を明確にする目的で有効です。Enter 押下の効果がなかったのでは?という誤解を避けられます。 Q. TaskCanceledException と OperationCanceledException はどちらを catch すべき? A. 原則は後者(親クラス)を when (oce.CancellationToken == token) でガードして捕捉します。API 実装差や将来の変更にも強くなります。 Q. cancelTask を用意せずに Ctrl+C などの OS シグナルで代用しても良い? A. コンソールアプリでは可能です。ただし CancelKeyPress ハンドラの中で cts.Cancel() を呼び、UI スレッドをブロックしないように注意します。

まとめ

Enter キーでキャンセルするサンプルが出力する「Cancelled」と「Completed before cancel processed」の違いは、WhenAny がどちらを先に観測したか と、キャンセル要求が処理側でいつ観測されたか の二軸で決まります。キャンセルは“割り込み”ではなく“協調”。したがって競合は避けられませんが、token.AsTask() パターンや CancelAsync()、適切な catch とログ設計を採ることで、非決定的な順序を仕様として吸収し、読みやすく安全なコードにできます。現場の要件に合わせて、どのメッセージをいつ出すかを 意図的に 設計しましょう。


付録:本稿で使った「より実践的」なダウンロード例

実際の HttpClient を使うと、キャンセルはより現実に近く観測できます。


static async Task<int> SumPageSizesHttpAsync(IEnumerable<string> urls, CancellationToken token)
{
    // 実アプリでは IHttpClientFactory の利用も検討
    using var http = new HttpClient();

```
var tasks = urls.Select(async url =&gt;
{
    // トークンを必ず渡す
    using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
    resp.EnsureSuccessStatusCode();

    // 一部だけ読む場合でも適宜キャンセル観測
    var bytes = await resp.Content.ReadAsByteArrayAsync(token);
    token.ThrowIfCancellationRequested();
    return bytes.Length;
});

var sizes = await Task.WhenAll(tasks);
return sizes.Sum();
```

} 

この関数を先のフレームに差し替え、帯域制限ツールや低速プロキシを併用すると、ケース①〜③を実運用に近い形で検証できます。


付録:状態とメッセージの対応表(実装時のチートシート)

観測点状態想定分岐メッセージ例
WhenAnyfinishedTask == cancelTaskキャンセル起点が先(この後 await work の結果で①/②が決まる)
await workRanToCompletionケース②Completed before cancel processed
await workCanceledケース①has been cancelled
WhenAnyfinishedTask == workケース③2メッセージは出ない(必要なら自前で結果/例外を整形)

付録:ユニットテストの素片(ロジック保証)

UI 入力の代わりにタイマーでキャンセルを発火し、分岐の健全性だけを検証する例です。


// 擬似コード(xUnit 想定)
[Fact]
public async Task When_CancelBeforeCompletion_PrintsCancelled()
{
    using var cts = new CancellationTokenSource();
    var work = Task.Run(async () => { await Task.Delay(5000, cts.Token); }, cts.Token);
    var cancelSignal = Task.Delay(100).ContinueWith(_ => cts.Cancel());

```
var first = await Task.WhenAny(work, cancelSignal);
Assert.NotSame(first, work); // キャンセルが先

try { await work; Assert.True(false, "should be cancelled"); }
catch (OperationCanceledException) { Assert.True(true); }
```

} 

最後に:現場適用の勘所

  • API 呼び出しには必ず CancellationToken を渡し、観測しやすいコード にする。
  • キャンセルと完了が「どちら先でも正しい」設計にし、メッセージで状況を明確化する。
  • ログ・計測を仕込んで、再現不能なバグ報告を減らす。
  • UI/サービス/バッチなどアプリ種別に合わせ、CancelAsync()・再試行・タイムアウト戦略と組み合わせる。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次