C# の CancellationToken は「キャンセル要求を安全に伝達する」ための仕組みです。基本は ThrowIfCancellationRequested() を呼ぶだけですが、その下にはRegister() コールバック・WaitHandle 連携・CanBeCanceled 最適化・Dispose 管理など、知っておかないと落とし穴にはまる仕組みが多く存在します。
本記事では CancellationToken の内部構造から始まり、3つのキャンセル確認方法・Register() の使い方・非同期チェーン全体へのトークン伝播・CreateLinkedTokenSource・ASP.NET Core での活用・グレースフルシャットダウンまで体系的に解説します。
- CancellationToken の構造と3つの役割
- 3つのキャンセル確認方法
- Register() — キャンセル時に呼ばれるコールバック
- CancellationToken.None・default・CanBeCanceled の違い
- 非同期チェーン全体へのトークン伝播
- タイムアウト設定 — CancelAfter vs コンストラクター引数
- CreateLinkedTokenSource — 複数のキャンセル信号を統合する
- CPU バウンド処理でのキャンセルポーリング
- OperationCanceledException と TaskCanceledException の継承関係
- ASP.NET Core での CancellationToken 活用
- グレースフルシャットダウンの実装パターン
- よくある間違いと対処法
- よくある質問
- まとめ
CancellationToken の構造と3つの役割
キャンセル処理には必ず3つのオブジェクトが登場します。それぞれの役割を最初に理解することが、正確な実装への近道です。
| クラス / 構造体 | 役割 | 使うのは誰 |
|---|---|---|
CancellationTokenSource |
キャンセル信号の発行者。Cancel() / CancelAfter() を呼ぶ | 呼び出し側(コントローラー・UI・タイマー) |
CancellationToken |
キャンセル信号の受信者。メソッドに渡して状態を確認する | 実行されるメソッド(非同期・同期問わず) |
CancellationTokenRegistration |
Register() で登録したコールバックの登録解除ハンドル | Register() を使う場合のみ(Dispose が必要) |
// ① 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 です(CancellationTokenSource は class)。トークンをコピーしても同じキャンセル状態を参照します。内部で CancellationTokenSource への参照を保持しているため、複数の場所にコピーを渡してもすべて同じ Cancel() に反応します。3つのキャンセル確認方法
キャンセルを確認する方法は3種類あります。それぞれ使いどころが異なります。
| 方法 | API | 動作 | 使いどころ |
|---|---|---|---|
| ポーリング | IsCancellationRequested |
true/false を返す(例外を投げない) | ループ内で自前処理を入れたい場合 |
| 例外スロー | ThrowIfCancellationRequested() |
キャンセル済みなら OperationCanceledException を投げる | 最も一般的。async メソッドの要所に置く |
| ブロッキング待機 | WaitHandle.WaitOne() |
キャンセルまでスレッドをブロック | 旧来の同期コードや WaitHandle との連携 |
// ① 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(1000, token)・httpClient.GetAsync(url, token)・stream.ReadAsync(buffer, offset, count, token) など、多くの非同期 API が最後の引数に CancellationToken を受け付けます。これらは内部で ThrowIfCancellationRequested() 相当の処理をしているため、トークンを渡すだけで自動的にキャンセルに応答します。Register() — キャンセル時に呼ばれるコールバック
token.Register() を使うと、キャンセルが発生したとき(または既にキャンセル済みなら即座に)コールバックを実行できます。非同期に対応していない古い API(ソケット・プロセス・独自ブロッキング処理など)をCancellationToken に対応させる主要手段です。
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 が必須(解除しないとコールバックが残り続ける)
// 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;
}
cts.Cancel() を呼ぶと、登録されたすべてのコールバックがCancel() を呼んだスレッド上で同期的に実行されます。コールバック内で重い処理や lock を使う場合は注意が必要です。また、CancellationTokenRegistration を Dispose しないと、CancellationTokenSource がコールバックへの参照を保持し続け、メモリリークの原因になります。必ず using で管理してください。CancellationToken.None・default・CanBeCanceled の違い
メソッドの引数として CancellationToken を受け取る場合、「キャンセルが不要なとき」や「呼び出し元がトークンを渡さないとき」の扱いを理解しておく必要があります。
// 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(); // 即スロー
// 長時間処理の初期チェック: キャンセル不可能なら準備処理をスキップできる
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: トークンをもらっているが子メソッドへ渡していない
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: すべての非同期呼び出しにトークンを渡す
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);
}
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() は後からタイムアウトを設定・変更できます。
// ① コンストラクター: 作成と同時にタイマーが始まる 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秒に延長
// 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(コンストラクター引数や CancelAfter())は、内部でタイマーを保持しています。Dispose() を呼ばないとタイマースレッドが解放されません。必ず using var cts = new CancellationTokenSource(...) で管理してください。一方、タイムアウトなしの場合もガベージコレクション任せにせず、明示的に Dispose することを推奨します。CreateLinkedTokenSource — 複数のキャンセル信号を統合する
CancellationTokenSource.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() で作られた CancellationTokenSource は、元のトークンへの購読(イベント登録)を内部に保持しています。Dispose() を呼ばないとこの購読が残り続け、メモリリークになります。using var linkedCts = ... で確実に解放してください。CPU バウンド処理でのキャンセルポーリング
I/O 処理と異なり、CPU バウンドの処理(数値計算・画像処理・データ変換など)はawait ポイントがないため、ループ内で定期的にキャンセルをポーリングする必要があります。
// 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 では、リクエストのライフサイクルやアプリケーションのシャットダウンと連動したトークンが標準で提供されます。
// 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");
}
}
// バックグラウンドサービスでのシャットダウン対応
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("アプリ停止完了"));
}
}
HttpContext.RequestAborted: クライアントが接続を切断したとき。コントローラーの CancellationToken 引数に自動バインドされる。IHostApplicationLifetime.ApplicationStopping: Ctrl+C や SIGTERM を受け取り、シャットダウン処理が始まったとき。IHostApplicationLifetime.ApplicationStarted: アプリが完全に起動したとき。BackgroundService.ExecuteAsync の stoppingToken: ApplicationStopping と同じトークン。グレースフルシャットダウンの実装パターン
コンソールアプリケーションやワーカープロセスで、Ctrl+C(SIGINT)や SIGTERM を受け取ったときに安全に処理を終了するパターンを紹介します。
// シンプルなコンソールアプリでのグレースフル停止
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 も利用可能
// 進行中の処理を追跡して全完了を待つパターン
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();
}
}
よくある間違いと対処法
// NG: Cancel() した後も同じ CTS を再利用しようとする var cts = new CancellationTokenSource(); cts.Cancel(); // cts.TryReset() は .NET 6+ で追加されたが制約が多い // cts.Cancel() 後に再度 Cancel() しても問題ないが、 // 一度キャンセルした CTS を「未キャンセル状態に戻す」ことはできない // → 新しい CTS を作り直す cts.Dispose(); cts = new CancellationTokenSource(); // 新規作成
// 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}");
}
}
// 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 がスローされる!
// 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 される
よくある質問
token.Register() を使って、キャンセル時にその API の中断メソッドを呼ぶコールバックを登録します。例:TcpClient.Close()・Process.Kill()・独自の StopFlag を true に設定するなど。コールバック内でリソースを閉じることで、待機している API 呼び出しがエラーで戻ってきます。その後 when (token.IsCancellationRequested) で捕捉し、OperationCanceledException に変換してください。async void はイベントハンドラー専用で、キャンセルの管理が難しくなります。async void 内で OperationCanceledException が上位にキャッチされずにスローされると、プロセスがクラッシュします。async Task に変更するか、async void 内で try-catch (OperationCanceledException) を使って明示的に処理してください。CancellationToken.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 完全ガイドで解説しています。

