【C#】CancellationToken完全ガイド|Register・伝播・Dispose・ASP.NET Core連携まで

【C#】非同期タスクのキャンセル方法|CancellationTokenの基本 C#

C# の CancellationToken は「キャンセル要求を安全に伝達する」ための仕組みです。基本は ThrowIfCancellationRequested() を呼ぶだけですが、その下にはRegister() コールバック・WaitHandle 連携・CanBeCanceled 最適化・Dispose 管理など、知っておかないと落とし穴にはまる仕組みが多く存在します。

本記事では CancellationToken の内部構造から始まり、3つのキャンセル確認方法・Register() の使い方・非同期チェーン全体へのトークン伝播・CreateLinkedTokenSource・ASP.NET Core での活用・グレースフルシャットダウンまで体系的に解説します。

スポンサーリンク

CancellationToken の構造と3つの役割

キャンセル処理には必ず3つのオブジェクトが登場します。それぞれの役割を最初に理解することが、正確な実装への近道です。

クラス / 構造体 役割 使うのは誰
CancellationTokenSource キャンセル信号の発行者。Cancel() / CancelAfter() を呼ぶ 呼び出し側(コントローラー・UI・タイマー)
CancellationToken キャンセル信号の受信者。メソッドに渡して状態を確認する 実行されるメソッド(非同期・同期問わず)
CancellationTokenRegistration Register() で登録したコールバックの登録解除ハンドル Register() を使う場合のみ(Dispose が必要)
3役割の全体像
// ① CancellationTokenSource: キャンセル信号の発行者
using var cts = new CancellationTokenSource();

// ② CancellationToken: 実行メソッドへ渡す
CancellationToken token = cts.Token;

// ③ コールバック登録(後述): Dispose が必要
using CancellationTokenRegistration reg = token.Register(() =>
    Console.WriteLine("キャンセルコールバック呼ばれた"));

// キャンセル発行
cts.Cancel();   // reg のコールバックが同期的に実行される

// IsCancellationRequested で確認
Console.WriteLine(token.IsCancellationRequested); // True
Console.WriteLine(cts.IsCancellationRequested);   // True(Source 側でも確認可能)
CancellationToken は値型(struct)
CancellationTokenstruct です(CancellationTokenSource は class)。トークンをコピーしても同じキャンセル状態を参照します。内部で CancellationTokenSource への参照を保持しているため、複数の場所にコピーを渡してもすべて同じ Cancel() に反応します。

3つのキャンセル確認方法

キャンセルを確認する方法は3種類あります。それぞれ使いどころが異なります。

方法 API 動作 使いどころ
ポーリング IsCancellationRequested true/false を返す(例外を投げない) ループ内で自前処理を入れたい場合
例外スロー ThrowIfCancellationRequested() キャンセル済みなら OperationCanceledException を投げる 最も一般的。async メソッドの要所に置く
ブロッキング待機 WaitHandle.WaitOne() キャンセルまでスレッドをブロック 旧来の同期コードや WaitHandle との連携
3つの確認方法
// ① IsCancellationRequested: 例外を投げずに確認
static async Task ProcessLoopAsync(int[] items, CancellationToken token)
{
    foreach (var item in items)
    {
        if (token.IsCancellationRequested)
        {
            // クリーンアップ処理をしてから抜ける
            Console.WriteLine("キャンセル検出。途中経過を保存します。");
            SaveProgress();
            return;   // または token.ThrowIfCancellationRequested() でスロー
        }
        await ProcessItemAsync(item);
    }
}

// ② ThrowIfCancellationRequested: 最も一般的
static async Task DownloadAsync(string url, CancellationToken token)
{
    for (int retry = 0; retry < 3; retry++)
    {
        token.ThrowIfCancellationRequested(); // キャンセル済みなら即スロー
        try
        {
            var data = await FetchAsync(url, token);
            return;
        }
        catch (HttpRequestException) when (retry < 2)
        {
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retry)), token);
        }
    }
}

// ③ WaitHandle: 同期コードや Win32 API との連携
static void LegacySyncWork(CancellationToken token)
{
    // WaitHandle.WaitAny で複数ハンドルを一度に待機できる
    int index = WaitHandle.WaitAny(
        new[] { token.WaitHandle, someOtherHandle },
        TimeSpan.FromSeconds(10));

    if (index == 0) // token.WaitHandle がシグナルされた = キャンセル
        throw new OperationCanceledException(token);
}
Task.Delay / HttpClient 等のキャンセル対応 API
Task.Delay(1000, token)httpClient.GetAsync(url, token)stream.ReadAsync(buffer, offset, count, token) など、多くの非同期 API が最後の引数に CancellationToken を受け付けます。これらは内部で ThrowIfCancellationRequested() 相当の処理をしているため、トークンを渡すだけで自動的にキャンセルに応答します。

Register() — キャンセル時に呼ばれるコールバック

token.Register() を使うと、キャンセルが発生したとき(または既にキャンセル済みなら即座に)コールバックを実行できます。非同期に対応していない古い API(ソケット・プロセス・独自ブロッキング処理など)をCancellationToken に対応させる主要手段です。

Register() の基本と登録解除
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

// Register は CancellationTokenRegistration を返す(IDisposable)
using CancellationTokenRegistration reg = token.Register(
    state => Console.WriteLine($"キャンセル: {state}"),
    state: "接続解除処理");

// 既にキャンセル済みなら Register 呼び出し時点で即実行される
cts.Cancel(); // → "キャンセル: 接続解除処理" がここで実行される

// using 抜けで reg.Dispose() が呼ばれ、登録が解除される
// → cts を使い回す場合は Dispose が必須(解除しないとコールバックが残り続ける)
Register() で非キャンセル対応 API をラップする
// TcpClient の接続をキャンセル対応にする例
static async Task<TcpClient> ConnectWithCancelAsync(
    string host, int port, CancellationToken token)
{
    var client = new TcpClient();
    // キャンセル時にソケットを強制クローズ → ConnectAsync が例外で返る
    using CancellationTokenRegistration reg = token.Register(
        () => client.Close());  // Close() は ConnectAsync をアボートする

    try
    {
        await client.ConnectAsync(host, port); // .NET < 5 でトークン非対応の場合
        return client;
    }
    catch (ObjectDisposedException) when (token.IsCancellationRequested)
    {
        // Close() による破棄を OperationCanceledException に変換
        throw new OperationCanceledException(token);
    }
}

// 独自のブロッキング処理をキャンセル対応にする(TaskCompletionSource パターン)
static async Task<string> BlockingIoAsync(CancellationToken token)
{
    var tcs = new TaskCompletionSource<string>();

    // キャンセル時に TaskCompletionSource をキャンセル状態にする
    using CancellationTokenRegistration reg = token.Register(
        () => tcs.TrySetCanceled(token));

    // バックグラウンドスレッドでブロッキング処理を実行
    _ = Task.Run(() =>
    {
        try
        {
            string result = LegacyBlockingRead();
            tcs.TrySetResult(result);   // 完了を通知
        }
        catch (Exception ex)
        {
            tcs.TrySetException(ex);    // 例外を伝播
        }
    });

    // キャンセルまたは正常完了を待つ
    // → token がキャンセルされると tcs が TrySetCanceled され OperationCanceledException がスローされる
    return await tcs.Task;
}
Register() のコールバックは同期的に実行される
cts.Cancel() を呼ぶと、登録されたすべてのコールバックがCancel() を呼んだスレッド上で同期的に実行されます。コールバック内で重い処理や lock を使う場合は注意が必要です。また、CancellationTokenRegistrationDispose しないと、CancellationTokenSource がコールバックへの参照を保持し続け、メモリリークの原因になります。必ず using で管理してください。

CancellationToken.None・default・CanBeCanceled の違い

メソッドの引数として CancellationToken を受け取る場合、「キャンセルが不要なとき」や「呼び出し元がトークンを渡さないとき」の扱いを理解しておく必要があります。

CancellationToken.None と default の挙動
// CancellationToken.None と default(CancellationToken) は等価
// どちらも「絶対にキャンセルされないトークン」を表す
CancellationToken t1 = CancellationToken.None;
CancellationToken t2 = default;
Console.WriteLine(t1 == t2);                  // True
Console.WriteLine(t1.CanBeCanceled);          // False: キャンセル不可能
Console.WriteLine(t1.IsCancellationRequested); // False

// new CancellationToken() も同じ
var t3 = new CancellationToken();             // CanBeCanceled: False

// ThrowIfCancellationRequested は CanBeCanceled が false なら
// IsCancellationRequested チェック自体をスキップして高速に返る
// → None/default を渡してもパフォーマンス上の問題なし
t1.ThrowIfCancellationRequested(); // 何も起きない(高速)

// new CancellationToken(canceled: true) は最初からキャンセル済み
var alwaysCanceled = new CancellationToken(canceled: true);
Console.WriteLine(alwaysCanceled.IsCancellationRequested); // True
alwaysCanceled.ThrowIfCancellationRequested(); // 即スロー
CanBeCanceled を活用したショートサーキット
// 長時間処理の初期チェック: キャンセル不可能なら準備処理をスキップできる
static async Task HeavyOperationAsync(
    SomeData data,
    CancellationToken token = default)
{
    // CanBeCanceled が false (= None/default) なら高コストな前処理も不要
    if (token.CanBeCanceled)
    {
        token.ThrowIfCancellationRequested();
    }

    // ... 処理本体
}

// ライブラリ側のパターン: token を省略可能にしてデフォルトを None に
static async Task<string> FetchDataAsync(
    string url,
    CancellationToken cancellationToken = default) // 省略時は None
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url, cancellationToken);
}
トークンの種類 CanBeCanceled IsCancellationRequested 典型的な用途
CancellationToken.None false false キャンセル不要な呼び出し・テスト
default(CancellationToken) false false 省略引数のデフォルト値として
new CancellationToken() false false 上の2つと同じ
new CancellationToken(true) true true 常にキャンセル済みの状態をシミュレート
cts.Token(未キャンセル) true false 通常の使い方
cts.Token(Cancel後) true true キャンセル済みトークン

非同期チェーン全体へのトークン伝播

キャンセルを確実に動作させるためには、すべての非同期メソッドがトークンを引数に受け取り、子メソッドへ渡す必要があります。途中でトークンを渡し忘れると、そのメソッド以降はキャンセルに応答しなくなります。

NG: トークンが途中で切れる
// NG: トークンをもらっているが子メソッドへ渡していない
static async Task OrderProcessingAsync(int orderId, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    var order = await GetOrderAsync(orderId);          // ← token 渡し忘れ!
    var items = await GetOrderItemsAsync(orderId);     // ← token 渡し忘れ!
    await ShipOrderAsync(order, items);                // ← token 渡し忘れ!
    // GetOrderAsync() 等が長時間かかっても Cancel() が届かない
}
OK: 全メソッドにトークンを伝播
// OK: すべての非同期呼び出しにトークンを渡す
static async Task OrderProcessingAsync(int orderId, CancellationToken token)
{
    token.ThrowIfCancellationRequested();

    var order = await GetOrderAsync(orderId, token);         // ✓
    var items = await GetOrderItemsAsync(orderId, token);    // ✓
    await ValidateStockAsync(items, token);                  // ✓
    await ShipOrderAsync(order, items, token);               // ✓
}

// 各子メソッドも同様にトークンを受け取って渡す
static async Task<Order> GetOrderAsync(int id, CancellationToken token)
{
    // DB アクセスにもトークンを渡す(EF Core / Dapper 等が対応)
    return await dbContext.Orders
        .Where(o => o.Id == id)
        .FirstOrDefaultAsync(token) ?? throw new OrderNotFoundException(id);
}
EF Core / HttpClient / Stream などの標準ライブラリ対応状況
dbContext.SaveChangesAsync(token)dbContext.ToListAsync(token)(EF Core)、httpClient.GetAsync(url, token)stream.ReadAsync(buffer, token)sqlCommand.ExecuteReaderAsync(token) など、現代の非同期 API は広くトークンをサポートしています。引数の最後に渡すだけで自動的にキャンセルに応答します。
非同期ストリームへのトークン伝播
// IAsyncEnumerable でのトークン伝播
static async IAsyncEnumerable<ProcessResult> ProcessStreamAsync(
    IAsyncEnumerable<RawData> source,
    [EnumeratorCancellation] CancellationToken token = default)
{
    await foreach (var item in source.WithCancellation(token))
    {
        token.ThrowIfCancellationRequested();
        yield return await TransformAsync(item, token);
    }
}

// 呼び出し側
await foreach (var result in ProcessStreamAsync(dataStream, token))
{
    Console.WriteLine(result);
}

タイムアウト設定 — CancelAfter vs コンストラクター引数

タイムアウトによるキャンセルには2通りの方法があります。コンストラクター引数は作成時に即座にタイマーが始まり、CancelAfter() は後からタイムアウトを設定・変更できます。

CancelAfter() と コンストラクター引数の違い
// ① コンストラクター: 作成と同時にタイマーが始まる
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// ここから5秒後に自動キャンセル

// ② CancelAfter(): 後からタイムアウトを設定
using var cts2 = new CancellationTokenSource();
// 何か前処理...
cts2.CancelAfter(TimeSpan.FromSeconds(5)); // 前処理後からタイマー開始

// ③ CancelAfter() でタイムアウトをリセット(延長)
using var cts3 = new CancellationTokenSource();
cts3.CancelAfter(TimeSpan.FromSeconds(5));
// ... 何か処理 ...
cts3.CancelAfter(TimeSpan.FromSeconds(10)); // タイムアウトを延長
// CancelAfter は何度でも呼べる(最後の呼び出しが有効)

// ④ int ミリ秒での指定も可能
using var cts4 = new CancellationTokenSource(3000); // 3秒
cts4.CancelAfter(5000);                              // 5秒に延長
try-finally での CancellationTokenSource のライフサイクル
// CancellationTokenSource は IDisposable → using で管理する
static async Task<string> FetchWithTimeoutAsync(string url)
{
    // using var で Dispose を保証
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    using var client = new HttpClient();

    try
    {
        return await client.GetStringAsync(url, cts.Token);
    }
    catch (OperationCanceledException) when (cts.IsCancellationRequested)
    {
        throw new TimeoutException($"30秒以内に応答がありませんでした: {url}");
    }
}
// using 抜けで cts.Dispose() が自動呼び出し → タイマースレッドが解放される
CancellationTokenSource を Dispose しないとタイマーリソースが漏れる
特にタイムアウトを設定した CancellationTokenSource(コンストラクター引数や CancelAfter())は、内部でタイマーを保持しています。Dispose() を呼ばないとタイマースレッドが解放されません。必ず using var cts = new CancellationTokenSource(...) で管理してください。一方、タイムアウトなしの場合もガベージコレクション任せにせず、明示的に Dispose することを推奨します。

CreateLinkedTokenSource — 複数のキャンセル信号を統合する

CancellationTokenSource.CreateLinkedTokenSource() を使うと、複数のキャンセルソース(ユーザー操作・タイムアウト・親タスクなど)を一つのトークンにまとめられます。いずれかのソースがキャンセルされると、リンクされたトークンも自動的にキャンセルされます。

CreateLinkedTokenSource の基本パターン
// 複数のキャンセルソースを統合する
using var userCts     = new CancellationTokenSource();            // ユーザー操作
using var timeoutCts  = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // タイムアウト
using var linkedCts   = CancellationTokenSource.CreateLinkedTokenSource(
    userCts.Token, timeoutCts.Token);

// linkedCts.Token は両方に反応する
try
{
    await LongOperationAsync(linkedCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
    Console.WriteLine("30秒タイムアウト");
}
catch (OperationCanceledException) when (userCts.IsCancellationRequested)
{
    Console.WriteLine("ユーザーキャンセル");
}
親タスクのトークンと子タスクのトークンを連結
// ASP.NET Core でよく使うパターン:
// 「リクエストのキャンセル(RequestAborted)」と「操作固有のタイムアウト」を合わせる
static async Task<ApiResult> HandleRequestAsync(
    HttpContext httpContext,
    CancellationToken operationTimeout)
{
    // リクエストキャンセル + 操作タイムアウトの両方に対応
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        httpContext.RequestAborted,  // ユーザーがブラウザを閉じたとき
        operationTimeout);           // API 呼び出し単体のタイムアウト

    return await DoBusinessLogicAsync(linkedCts.Token);
}

// 子タスクに外部トークンと独自タイムアウトを渡すパターン
static async Task ProcessChildTaskAsync(CancellationToken parentToken)
{
    // 親のキャンセルに加え、子タスク固有の短いタイムアウトを設定
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedCts  = CancellationTokenSource.CreateLinkedTokenSource(
        parentToken, timeoutCts.Token);

    await ChildOperationAsync(linkedCts.Token);
}
CreateLinkedTokenSource で作った CTS も必ず Dispose する
CreateLinkedTokenSource() で作られた CancellationTokenSource は、元のトークンへの購読(イベント登録)を内部に保持しています。Dispose() を呼ばないとこの購読が残り続け、メモリリークになります。using var linkedCts = ... で確実に解放してください。

CPU バウンド処理でのキャンセルポーリング

I/O 処理と異なり、CPU バウンドの処理(数値計算・画像処理・データ変換など)はawait ポイントがないため、ループ内で定期的にキャンセルをポーリングする必要があります。

CPU バウンドループでのポーリング
// CPU 集中型の処理でキャンセルをポーリングする
static async Task<double[]> ComputeHeavyAsync(
    double[] data,
    CancellationToken token)
{
    // Task.Run でスレッドプールに委譲(UI スレッドをブロックしない)
    return await Task.Run(() =>
    {
        var result = new double[data.Length];

        for (int i = 0; i < data.Length; i++)
        {
            // 一定間隔でキャンセルを確認
            // 毎反復チェックしてもよいが、コストが気になるなら数百回に1回でも可
            if (i % 100 == 0)
                token.ThrowIfCancellationRequested();

            result[i] = Math.Sqrt(data[i]) * Math.Sin(data[i]);
        }

        return result;
    }, token);
    // Task.Run の token はタスクが開始前にキャンセルされた場合の保険
}

// Parallel.For でのキャンセル
static void ParallelCompute(int[] items, CancellationToken token)
{
    var options = new ParallelOptions
    {
        CancellationToken = token,
        MaxDegreeOfParallelism = Environment.ProcessorCount
    };

    Parallel.For(0, items.Length, options, i =>
    {
        // options.CancellationToken を渡すことで Parallel が自動的にキャンセルを監視
        ProcessItem(items[i]);
    });
    // キャンセル時は OperationCanceledException がスローされる
}
ポーリング頻度の目安
毎反復チェックすること自体のコストは非常に小さいため、基本的に毎回チェックして問題ありません。ただし反復1回が数マイクロ秒未満の超高速ループでは、if (i % 1000 == 0) のように間引くことでキャンセルチェックのオーバーヘッドを1/1000に抑えられます。キャンセルの応答性(最大遅延)と計算効率のバランスで決めてください。

OperationCanceledException と TaskCanceledException の継承関係

キャンセル時にスローされる例外は2種類あります。継承関係を理解しておくと、どちらをキャッチするべきかが明確になります。

例外クラス 親クラス スローされる状況
OperationCanceledException SystemException token.ThrowIfCancellationRequested() が呼ばれたとき
TaskCanceledException OperationCanceledException Task がキャンセルされたとき(Task.Delay 等)
例外のキャッチパターン
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(100)); // 100ms後にキャンセル

// TaskCanceledException は OperationCanceledException の派生クラス
// → OperationCanceledException を catch すれば両方まとめて捕捉できる
try
{
    await Task.Delay(TimeSpan.FromSeconds(10), cts.Token);
}
catch (OperationCanceledException ex)
{
    // TaskCanceledException も OperationCanceledException でキャッチできる
    Console.WriteLine($"例外の型: {ex.GetType().Name}");
    // → TaskCanceledException

    // ex.CancellationToken でどのトークンによるキャンセルか確認できる
    Console.WriteLine(ex.CancellationToken == cts.Token); // True
}

// 細かく分けたい場合
try
{
    await SomethingAsync(cts.Token);
}
catch (TaskCanceledException ex)
{
    Console.WriteLine("タスクがキャンセルされた");
}
catch (OperationCanceledException ex)
{
    Console.WriteLine("操作がキャンセルされた");
}

// 通常は OperationCanceledException だけキャッチすれば十分
// キャンセルを「正常な終了」として扱う場合はそのまま流す
static async Task RunWithOptionalCancelAsync(CancellationToken token)
{
    try
    {
        await DoWorkAsync(token);
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        // このキャンセルは想定内 → 何もしない(上位に伝播させない)
        return;
    }
    // 他の例外は上位へ
}

ASP.NET Core での CancellationToken 活用

ASP.NET Core では、リクエストのライフサイクルやアプリケーションのシャットダウンと連動したトークンが標準で提供されます。

HttpContext.RequestAborted — リクエストキャンセル
// Controller での使い方: HttpContext.RequestAborted を使う
[HttpGet("{id}")]
public async Task<IActionResult> GetProductAsync(int id, CancellationToken cancellationToken)
{
    // cancellationToken は ASP.NET Core が自動的に HttpContext.RequestAborted を
    // バインドしてくれる(引数名は何でもよい)
    // ユーザーがブラウザを閉じたりリクエストをキャンセルした場合に自動発火

    var product = await _repository.GetByIdAsync(id, cancellationToken);
    if (product == null) return NotFound();

    return Ok(product);
}

// 手動でリクエストキャンセルを使う場合
[HttpPost("export")]
public async Task<IActionResult> ExportDataAsync()
{
    var token = HttpContext.RequestAborted;

    try
    {
        var data = await _exportService.ExportAsync(token);
        return File(data, "application/csv");
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        // ユーザーがキャンセルした → 503 ではなく静かに終了
        return StatusCode(499, "Request cancelled by client");
    }
}
IHostApplicationLifetime — グレースフルシャットダウン
// バックグラウンドサービスでのシャットダウン対応
public class DataSyncService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // stoppingToken はアプリ停止時(Ctrl+C / SIGTERM)にキャンセルされる
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await SyncDataAsync(stoppingToken);
                await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // シャットダウン要求 → ループを抜けてグレースフルに終了
                Console.WriteLine("シャットダウン要求を受け取りました。同期処理を停止します。");
                break;
            }
        }
    }
}

// IHostApplicationLifetime を直接使う場合
public class MyService
{
    private readonly IHostApplicationLifetime _lifetime;

    public MyService(IHostApplicationLifetime lifetime)
    {
        _lifetime = lifetime;

        // アプリ停止開始時のコールバック
        _lifetime.ApplicationStopping.Register(() =>
            Console.WriteLine("アプリ停止開始"));

        // アプリ停止完了時のコールバック
        _lifetime.ApplicationStopped.Register(() =>
            Console.WriteLine("アプリ停止完了"));
    }
}
ASP.NET Core のトークン一覧
HttpContext.RequestAborted: クライアントが接続を切断したとき。コントローラーの CancellationToken 引数に自動バインドされる。
IHostApplicationLifetime.ApplicationStopping: Ctrl+CSIGTERM を受け取り、シャットダウン処理が始まったとき。
IHostApplicationLifetime.ApplicationStarted: アプリが完全に起動したとき。
BackgroundService.ExecuteAsyncstoppingToken: ApplicationStopping と同じトークン。

グレースフルシャットダウンの実装パターン

コンソールアプリケーションやワーカープロセスで、Ctrl+CSIGINT)や SIGTERM を受け取ったときに安全に処理を終了するパターンを紹介します。

Console.CancelKeyPress でのグレースフルシャットダウン
// シンプルなコンソールアプリでのグレースフル停止
static async Task Main(string[] args)
{
    using var cts = new CancellationTokenSource();

    // Ctrl+C または SIGTERM でキャンセル
    Console.CancelKeyPress += (sender, e) =>
    {
        e.Cancel = true;            // プロセスを即終了させない
        cts.Cancel();               // 代わりにキャンセルトークンを発火
        Console.WriteLine("
シャットダウン要求を受け取りました...");
    };

    // AppDomain.CurrentDomain.ProcessExit は SIGTERM 対応(Linux/Docker)
    // Docker/K8s では SIGTERM 後に SIGKILL まで猶予がある(kubectl: 30秒デフォルト)
    AppDomain.CurrentDomain.ProcessExit += (sender, e) =>
    {
        cts.Cancel();
    };

    Console.WriteLine("処理を開始します。Ctrl+C で安全に停止できます。");

    try
    {
        await RunMainLoopAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("グレースフルシャットダウン完了");
    }
}

// .NET 6 以降: CancellationTokenSource.CreateLinkedTokenSource も使える
// .NET 7 以降: Console.CancelKeyPress の代替として PosixSignalRegistration も利用可能
ProcessingTracker でシャットダウン時の作業待機
// 進行中の処理を追跡して全完了を待つパターン
public class GracefulShutdown : IDisposable
{
    private readonly CancellationTokenSource _cts = new();
    private int _activeCount = 0;
    private readonly TaskCompletionSource _drainTcs = new();

    public CancellationToken Token => _cts.Token;

    // 処理の開始を登録
    public IDisposable TrackOperation()
    {
        Interlocked.Increment(ref _activeCount);
        return new OperationTracker(this);
    }

    // 処理の完了を通知
    private void OnOperationComplete()
    {
        if (Interlocked.Decrement(ref _activeCount) == 0 && _cts.IsCancellationRequested)
            _drainTcs.TrySetResult();
    }

    // シャットダウン開始 + 全処理完了を待機(.NET 6+)
    public async Task ShutdownAsync(TimeSpan timeout)
    {
        _cts.Cancel();
        // Cancel() の前に全処理が完了していた場合はカウントが既に 0
        // → OnOperationComplete() から TrySetResult が呼ばれないので、ここで補完
        if (Volatile.Read(ref _activeCount) == 0)
            _drainTcs.TrySetResult();
        await _drainTcs.Task.WaitAsync(timeout);
    }

    public void Dispose() => _cts.Dispose();

    private class OperationTracker : IDisposable
    {
        private readonly GracefulShutdown _parent;
        public OperationTracker(GracefulShutdown parent) => _parent = parent;
        public void Dispose() => _parent.OnOperationComplete();
    }
}

よくある間違いと対処法

間違い① — Cancel() 後も使い続ける
// NG: Cancel() した後も同じ CTS を再利用しようとする
var cts = new CancellationTokenSource();
cts.Cancel();
// cts.TryReset() は .NET 6+ で追加されたが制約が多い

// cts.Cancel() 後に再度 Cancel() しても問題ないが、
// 一度キャンセルした CTS を「未キャンセル状態に戻す」ことはできない
// → 新しい CTS を作り直す
cts.Dispose();
cts = new CancellationTokenSource(); // 新規作成
間違い② — OperationCanceledException を Exception で握り潰す
// NG: Exception でキャッチするとキャンセルも吸収してしまう
static async Task ProcessAsync(CancellationToken token)
{
    try
    {
        await DoWorkAsync(token);
    }
    catch (Exception ex) // ← OperationCanceledException も捕まえてしまう!
    {
        Console.WriteLine($"エラー: {ex.Message}");
        // キャンセルが要求されているのに処理が続いてしまう
    }
}

// OK: キャンセルを再スローする
static async Task ProcessAsyncFixed(CancellationToken token)
{
    try
    {
        await DoWorkAsync(token);
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        throw; // キャンセルは再スローして上位に伝える
    }
    catch (Exception ex)
    {
        Console.WriteLine($"エラー: {ex.Message}");
    }
}
間違い③ — スレッドセーフでない複数スレッドからの Cancel()
// CancellationTokenSource.Cancel() はスレッドセーフ
// 複数スレッドから同時に呼ばれても安全
using var cts = new CancellationTokenSource();

var t1 = Task.Run(() => cts.Cancel()); // OK
var t2 = Task.Run(() => cts.Cancel()); // OK: 2回目以降は何も起きない
await Task.WhenAll(t1, t2);

// ただし Dispose() 後に Cancel() はスローを引き起こす
cts.Dispose();
// cts.Cancel(); // ← ObjectDisposedException がスローされる!
間違い④ — Token を長期間保持してソースを Dispose する
// NG: CTS を先に Dispose して Token を別の場所で使い続ける
CancellationToken token;
{
    using var cts = new CancellationTokenSource();
    token = cts.Token;
    // cts が using を抜けて Dispose される
}
// token.IsCancellationRequested を確認しようとすると
// ObjectDisposedException になる可能性がある
bool result = token.IsCancellationRequested; // 危険!

// OK: Token を使う間は CTS を生かしておく
using var ctsSafe = new CancellationTokenSource();
await DoWorkAsync(ctsSafe.Token);
// DoWork が終わってから ctsSafe が Dispose される

よくある質問

QCancellationToken を引数で受け取らないサードパーティ API に対してキャンセルを実装するには?
Atoken.Register() を使って、キャンセル時にその API の中断メソッドを呼ぶコールバックを登録します。例:TcpClient.Close()・Process.Kill()・独自の StopFlag を true に設定するなど。コールバック内でリソースを閉じることで、待機している API 呼び出しがエラーで戻ってきます。その後 when (token.IsCancellationRequested) で捕捉し、OperationCanceledException に変換してください。
QCancellationTokenSource を static フィールドに持つのは問題ありますか?
Aアプリ全体のシャットダウントークンなど「一度だけ」キャンセルするケースでは問題ありません。ただし、リクエストごと・操作ごとに使い回すと、Cancel() 後に次のリクエストでも「キャンセル済み」状態のトークンが使われてしまいます。スコープを絞った using var で管理するのが原則です。ASP.NET Core では DI コンテナのスコープライフタイムを活用してください。
Qasync void メソッドでキャンセルを扱う場合の注意点は?
Aasync void はイベントハンドラー専用で、キャンセルの管理が難しくなります。async void 内で OperationCanceledException が上位にキャッチされずにスローされると、プロセスがクラッシュします。async Task に変更するか、async void 内で try-catch (OperationCanceledException) を使って明示的に処理してください。
QCancellationToken を使うとパフォーマンスに影響しますか?
ACancellationToken.None(または default)を渡した場合、ThrowIfCancellationRequested()CanBeCanceled が false であることを確認して即リターンするため、ほぼゼロコストです。実際のトークン(cts.Token)を渡した場合でも、IsCancellationRequested の確認は volatile フィールドの読み取りに過ぎず、パフォーマンス上の問題はありません。毎ループイテレーションでのチェックも実用上の問題はありません。

まとめ

機能・パターン ポイント
3役割 CancellationTokenSource(発行)・CancellationToken(受信)・CancellationTokenRegistration(登録解除)
確認方法 IsCancellationRequested(ポーリング)・ThrowIfCancellationRequested(スロー)・WaitHandle(ブロッキング)
Register() キャンセル時のコールバック登録。CancellationTokenRegistration は必ず Dispose
None / default CanBeCanceled = false。キャンセル不可能なトークン。省略引数のデフォルト値に使う
トークン伝播 全非同期メソッドにトークンを渡す。途中で渡し忘れると以降はキャンセル不能に
CreateLinkedTokenSource 複数ソースを統合。LinkedCts 自体も using で Dispose が必要
タイムアウト コンストラクター引数 or CancelAfter()。CTS は using で Dispose しタイマー解放
CPU バウンド ループ内で ThrowIfCancellationRequested() をポーリング。Parallel.For は ParallelOptions.CancellationToken
ASP.NET Core RequestAborted(リクエストキャンセル)・ApplicationStopping(シャットダウン)・BackgroundService の stoppingToken
グレースフルシャットダウン Console.CancelKeyPress / ProcessExit でキャンセル発行。全処理の完了を待ってから終了

try-catch の基礎・カスタム例外・例外フィルター(when句)については例外処理完全ガイド例外フィルター(when句)完全ガイドを参照してください。

async/await の詳細(ConfigureAwait・ValueTask・IAsyncEnumerable・デッドロック対策)はasync/await 完全ガイドで解説しています。