🛑

いまさら聞けない CancellationToken

に公開

登壇してお話しました

.NETラボ 勉強会 2026年5月「いまさら聞けない CancellationToken」 というタイトルでお話ししてきました.
実務の話や CancellationToken の設計意図,内部実装などを話しました.

アーカイブもあります.
資料はこちら.
https://speakerdeck.com/htkym/jin-sarawen-kenaicancellationtoken

内容

何もしないのが一番早い

不要なことはしないのが一番速いと思います.

たとえば

  • ユーザーが画面遷移したので,元の HTTP リクエスト結果がもう要らない
  • タイムアウトを超えた DB アクセスをそれ以上待ちたくない
  • 並列探索で 1 件見つかったので,残りは打ち切りたい

パフォーマンス改善というと「速く実行する」ことに意識が向きますが,
そもそも実行しない 方が速いので,
不要になりそうな処理は止めるようにできることが大事だと思います.
そのための仕組みがキャンセルです.

昔はどうだったの...?

昔の .NET には Thread.Abort という「スレッドごと止める」仕組みがありました.
ただ,これは暴力的なキャンセルと比喩されます.

  • どの地点で止まるかわからない
  • 不変条件を壊す可能性がある
  • finally や後始末の考慮が難しい
  • そもそも非同期処理は専用スレッド上に乗っているとは限らない

※ とはいえ,当時はきっとこれがベストな選択肢だったのだと思います.

これに対して CancellationToken協調的キャンセル と言われます.
「止めたい側」が要求を出し,「実行している側」が安全なタイミングでそれを観測して止まります.
強制停止ではなく,止まり方を処理側で決められる ことがミソですね.

3 つの型

CancellationToken 周りは,この 3 つだけ覚えると大体のことがわかります.

  1. CancellationTokenSource
    • キャンセルを発動する側
  2. CancellationToken
    • キャンセル状態を監視する側
  3. OperationCanceledException
    • キャンセルされたことを表す例外

関係としてはこんなイメージです.

CancellationTokenSource  -> キャンセルを発動する
          |
          v
CancellationToken        -> キャンセルを監視する
          |
          v
OperationCanceledException -> キャンセルを伝播する

基本形はシンプルです.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

await DoWorkAsync(cts.Token);

大事なのは,トークンは明示的に引数で渡す ということです.
暗黙に伝わるものではありません.

CancellationTokenSourceCancellationToken

これは役割分担されたよくできた設計です.

もし CancellationToken 自体に Cancel() があったら,渡された側のどこからでもキャンセルを発動できてしまいます.
それは一見良さそうに見えますが,下位レイヤーのメソッドにそんな権限を持たせたくありません.
勝手にキャンセルされるのは困りますよね...

async Task SomeMiddlewareAsync(CancellationToken token)
{
    // token.Cancel(); みたいなことはできない
    await SomeLowerLayerAsync(token);
}
  • CancellationTokenSource所有者
  • CancellationToken読み取り専用ビュー

という役割分担になっています.

大半のコードは「キャンセルを起こす側」ではなく「観測する側」なので,この分離のおかげで API が安全です.

OperationCanceledException は異常系ではなく制御フロー

名前に Exception が付いているのでつい「失敗」として扱いたくなるのですが,
OperationCanceledException は多くの場合 正常な制御フロー です.

try
{
    await DoWorkAsync(ct);
}
catch (OperationCanceledException oce) when (oce.CancellationToken == ct)
{
    // キャンセルとして扱う
    throw;
}

アンチパターン

catch (Exception) で握り潰さない

try
{
    await DoWorkAsync(ct);
}
catch (Exception)
{
    // OperationCanceledException まで飲み込んでしまう
}

これをやると,キャンセルなのか本当の障害なのか分からなくなります.

TaskCanceledException だけを捕まえない

TaskCanceledExceptionOperationCanceledException の派生型です.
HttpClient などではこちらを見ることもありますが,基本は基底クラスの OperationCanceledException を捕まえる 方が安全ですね.

手で new OperationCanceledException() しない

自分で投げるくらいなら,まず ct.ThrowIfCancellationRequested() を使う方が自然です.
少なくとも,トークン情報なしの OperationCanceledException を適当に投げるのは避けた方がいいと思います.

後始末には同じトークンをそのまま流さない

たとえば,処理本体はキャンセルされてよいけれど,ロールバックやクリーンアップは絶対に最後までやりたいことがあります.
そのときに本処理と同じトークンをそのまま流すと,後始末までキャンセルされてしまいます.

try
{
    await transaction.CommitAsync(ct);
}
catch (OperationCanceledException)
{
    await transaction.RollbackAsync(CancellationToken.None);
    throw;
}

「ここはキャンセルに従うべき処理か,従ってはいけない処理か」を分けて考えないといけないですね.
何も考えずに渡すと面倒なことになるかもしれません...

Register

CancellationToken を伝搬していける場合はあまり使うことはないと思いますが,
キャンセル時のコールバックを登録できる Register() というメソッドがあります.

using var reg = ct.Register(() => CancelIoEx(handle, overlapped));
await ReadFromOSAsync(handle, buffer, overlapped);

こういう形にすると,「キャンセルされた瞬間に OS レベルの I/O キャンセルを飛ばす」ことができます.
戻り値は IDisposable なので,using でスコープを切れるのも綺麗です.

内部実装の話

CancellationToken は class ではなく 軽量な struct です.
中身はだいたい「対応する CancellationTokenSource への参照を持つ薄いビュー」と考えていいと思います.
伝搬させやすいように設計されています.

下のコードは,その考え方をかなり小さくしたサンプル実装です.
例えば MyCancellationToken は次のようになっています.

internal readonly struct MyCancellationToken
{
    private readonly MyCancellationTokenSource? _source;

    internal MyCancellationToken(MyCancellationTokenSource? source)
        => _source = source;

    public bool IsCancellationRequested
        => _source?.IsCancellationRequested ?? false;

    public void ThrowIfCancellationRequested()
    {
        if (IsCancellationRequested)
            throw new OperationCanceledException("キャンセルされました");
    }
}

本物の実装ではありませんが,token は source の状態を見るための薄いビュー という感覚はつかみやすいと思います.

また,Register() のコールバック管理も単純な配列追加ではなく,

  • 状態確認
  • 同期制御
  • 登録解除
  • キャンセル時の安全なコールバック実行

といったことをやっています.
重要なのが,コールバックはロックの外で実行する ことです.
ロックを握ったままコールバックを流すと,デッドロックや再入の問題になりやすいです.

簡略実装ですが,こんなイメージです.

internal class MyCancellationTokenSource
{
    private volatile bool _isCancellationRequested;
    private readonly Lock _lock = new();
    private List<Action>? _callbacks;

    public void Cancel()
    {
        List<Action>? callbacksToInvoke;
        lock (_lock)
        {
            if (_isCancellationRequested) return;
            _isCancellationRequested = true;
            callbacksToInvoke = _callbacks;
            _callbacks = null;
        }

        // コールバックはロック外で実行
        if (callbacksToInvoke is not null)
            foreach (var cb in callbacksToInvoke)
                cb();
    }
}

Register() の戻り値は IDisposable なので,using で囲って,登録の有効範囲をスコープに閉じ込めることができます.

using var cts = new CancellationTokenSource();

using (cts.Token.Register(() => Console.WriteLine("コールバック")))
{
    Console.WriteLine("  using ブロック処理中");
}

cts.Cancel();
Console.WriteLine("  Cancel() 後もコールバックは呼ばれない");

「登録は永続ではなく,必要なスコープだけにぶら下げられる」という点がわかりやすいと思います.

.NET Framework 時代と今の .NET では最適化の方向が違う

これは個人的には面白い話でした.

.NET Framework 4.8 では,「大量のスレッドが同時に Register/Dispose する」ような並列シナリオを意識した最適化が入っていました.
そのため,極端な並列ベンチマークでは .NET Framework 4.8 の方が強いケースもあります.

一方で,実際には,トークンを逐次的に渡していくユースケース が多く,現在もそれが主流だと思います.
なので現代の .NET では,複雑なロックフリー構造よりも,より単純なロックベースの実装に寄せつつ,逐次利用での速度やアロケーション削減を重視する方向に変わっています.
結果として,並列実行では .NET Framework 4.8 より遅くなりますが,逐次実行では .NET 10 の方が早く,さらに アロケーションが 0 になるなどの最適化がなされました.

この違いを Register/Dispose のベンチマークとして簡単に確認できるようにしてみました.

static void MeasureParallelTime(CancellationTokenSource cts, int iters)
{
    Parallel.For(0, 1000, _ => cts.Token.Register(() => { }).Dispose());

    var sw = Stopwatch.StartNew();
    Parallel.For(0, iters, _ => cts.Token.Register(() => { }).Dispose());
    sw.Stop();

    double nsPerOp = (double)sw.ElapsedTicks / Stopwatch.Frequency * 1_000_000_000.0 / iters;
    Console.WriteLine($"  時間: {FormatNanoseconds(nsPerOp, perOperation: true)}");
}

逐次側は時間だけではなく,割り当て量も一緒に見ます.

static void MeasureSerialWithMemory(CancellationTokenSource cts, int iters)
{
    long before = GC.GetAllocatedBytesForCurrentThread();
    var sw = Stopwatch.StartNew();

    for (int i = 0; i < iters; i++)
        cts.Token.Register(() => { }).Dispose();

    sw.Stop();
    long after = GC.GetAllocatedBytesForCurrentThread();

    double bytesPerOp = (double)(after - before) / iters;
    Console.WriteLine($"  割り当て: {bytesPerOp:F0} bytes/op");
}

結果はこんな感じ.

観点 .NET Framework 4.8 .NET 10
並列 Register/Dispose 高速寄り 遅め
逐次 Register/Dispose 約56 bytes/op 0 bytes/op
背景 分散・ロックフリー FreeNodeList 再利用

最近追加の API

  • .NET 6: CancellationTokenSource.TryReset() で再利用しやすくなった
  • .NET 8: CancelAsync() が追加
  • .NET 8: TimeProvider を使った時間制御がしやすくなった

まとめ

  • キャンセルは「止めるための機能」である前に「不要な仕事をしないため」
  • CancellationTokenSource / CancellationToken が役割分担している
  • 内部実装の最適化方針が,時代ごとの利用シナリオで変わってきたこと

Discussion