C# CancellationTokenの使い方完全ガイド|非同期処理のキャンセル実装とよくある悩みを解決
はじめに
C#で非同期処理や長時間処理を実装すると、「ユーザーがキャンセルボタンを押した」「HTTPリクエストが切断された」「一定時間以内に処理が終わらなかった」といった理由で、実行中の処理を終了したくなることがあります。
このような場面で使用するのが、CancellationTokenです。
ただし、CancellationTokenは実行中のスレッドを強制終了する機能ではありません。呼び出し側がキャンセルを要求し、処理側がその要求を検知して安全なタイミングで終了する「協調的キャンセル」の仕組みです。
本記事では、C#のCancellationTokenとCancellationTokenSourceの基本から、async・awaitを使った非同期処理、タイムアウト、例外処理、ASP.NET Core、BackgroundService、テスト方法まで詳しく解説します。
1. C#のCancellationTokenとは
1-1. CancellationTokenが必要になる理由
非同期処理を開始した後、その処理が常に最後まで必要とは限りません。
たとえば、検索画面でユーザーが別の条件を入力した場合、以前の検索処理を続ける必要はありません。ファイルのダウンロード中に画面を閉じた場合も、それ以上通信を続ける意味はないでしょう。
キャンセルできない処理を放置すると、次のような問題が発生します。
不要なCPU、メモリ、ネットワーク帯域を消費する
UIが操作に反応しにくくなる
古い処理結果で最新の画面を上書きする
アプリケーションの終了に時間がかかる
サーバー切断後もデータベース処理が続く
CancellationTokenを使えば、呼び出し側から処理側へ「この処理はもう必要ない」という意思を伝えられます。
1-2. 処理の「中断」ではなくキャンセルを通知する仕組み
CancellationTokenは、処理を外部から強制的に停止させるものではありません。
基本的な流れは次のとおりです。
呼び出し側が
CancellationTokenSourceを作成するCancellationTokenSource.Tokenを処理側へ渡す呼び出し側が
Cancelを呼び出す処理側がキャンセル要求を確認する
処理側が安全なタイミングで終了する
処理側がトークンを確認しなければ、Cancelを呼び出しても処理は止まりません。この点が、強制終了とは大きく異なります。
1-3. CancellationTokenとCancellationTokenSourceの違い
CancellationTokenとCancellationTokenSourceには、明確な役割の違いがあります。
| 型 | 主な役割 |
|---|---|
CancellationTokenSource | キャンセル要求を発行する |
CancellationToken | キャンセル要求を受け取る |
CancellationTokenSourceは、CancelやCancelAfterを使用してキャンセルを要求します。一方、CancellationTokenにはキャンセルを開始する機能がなく、状態の確認やコールバックの登録に使用します。
using var cts = new CancellationTokenSource();CancellationToken token = cts.Token;
// キャンセルを要求するcts.Cancel();
// キャンセルされたか確認するConsole.WriteLine(token.IsCancellationRequested); // True
処理を実行する側にはCancellationTokenだけを渡すことで、処理側が勝手に全体をキャンセルすることを防げます。
1-4. キャンセル処理が必要な代表的なケース
CancellationTokenがよく使用されるのは、次のような処理です。
HTTP通信
データベースへの問い合わせ
大きなファイルの読み書き
長時間の計算処理
バックグラウンド処理
UIアプリケーションの検索やダウンロード
ASP.NET Coreのリクエスト処理
アプリケーション終了時のサービス停止
一定時間で打ち切りたい処理
処理時間が長い場合だけでなく、「処理の途中で不要になる可能性があるか」という観点で導入を判断することが重要です。
2. CancellationTokenの基本的な使い方
2-1. CancellationTokenSourceを作成してトークンを取得する
まず、CancellationTokenSourceのインスタンスを作成します。
using var cts = new CancellationTokenSource();CancellationToken token = cts.Token;CancellationTokenSourceはIDisposableを実装しているため、使用範囲が明確な場合はusingで破棄します。特にタイマーやコールバック登録を使用する場合は、不要になった時点で適切に破棄することが大切です。
2-2. メソッドの引数としてCancellationTokenを渡す
キャンセル可能なメソッドでは、引数としてCancellationTokenを受け取ります。
static async Task ExecuteAsync(CancellationToken cancellationToken){await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);}呼び出し側では、CancellationTokenSource.Tokenを渡します。
using var cts = new CancellationTokenSource();await ExecuteAsync(cts.Token);
メソッド内で別の非同期メソッドを呼び出す場合も、受け取ったトークンをそのまま渡します。
static async Task LoadDataAsync(HttpClient httpClient,CancellationToken cancellationToken){string json = await httpClient.GetStringAsync("https://example.com/api/data",cancellationToken);await SaveDataAsync(json, cancellationToken);
}
2-3. Cancelメソッドでキャンセルを要求する
CancellationTokenSource.Cancelを呼び出すと、関連するトークンへキャンセル要求が通知されます。
using var cts = new CancellationTokenSource();Task task = ExecuteAsync(cts.Token);
cts.Cancel();
try{await task;}catch (OperationCanceledException){Console.WriteLine("処理がキャンセルされました。");}
Cancelは「停止命令」ではなく「キャンセル要求」です。実際に処理が終了するかどうかは、実行中の処理がトークンに対応しているかによって決まります。
また、Cancelを呼び出したスレッド上で、登録済みコールバックが同期的に実行されることがあります。コールバックに時間のかかる処理を登録すると、Cancel自体が長時間ブロックされる可能性があります。
2-4. IsCancellationRequestedでキャンセル状態を確認する
同期的なループや独自処理では、IsCancellationRequestedを確認できます。
static void ProcessItems(IEnumerable<string> items,CancellationToken cancellationToken){foreach (string item in items){if (cancellationToken.IsCancellationRequested){Console.WriteLine("キャンセル要求を検知しました。");return;} ProcessItem(item);}
}
IsCancellationRequestedを使うと、例外を発生させずにメソッドから戻れます。
ただし、呼び出し側が「正常終了」と「キャンセル」を区別する必要がある場合は、単純なreturnよりもThrowIfCancellationRequestedが適しています。
2-5. ThrowIfCancellationRequestedで処理を中断する
ThrowIfCancellationRequestedは、キャンセルが要求されている場合にOperationCanceledExceptionをスローします。
static void ProcessItems(IEnumerable<string> items,CancellationToken cancellationToken){foreach (string item in items){cancellationToken.ThrowIfCancellationRequested();ProcessItem(item);}}この方法には、次の利点があります。
呼び出し側がキャンセルを明確に判定できる
Taskがキャンセル状態として完了しやすいキャンセル処理の記述を統一できる
深い呼び出し階層からキャンセルを伝播できる
非同期メソッドやライブラリでは、基本的にThrowIfCancellationRequestedを使用するのが分かりやすい設計です。
2-6. CancellationToken.Noneを使用する場面
CancellationToken.Noneは、キャンセルされないトークンを表します。
await Task.Delay(1000, CancellationToken.None);主な使用場面は次のとおりです。
呼び出すAPIがトークンを必須としているが、キャンセルは不要
キャンセル後も必ず実行したい後処理
既存コードとの互換性を保つ
オプション引数の既定値としてキャンセルなしを表す
ただし、本来キャンセル要求を伝播すべき場所でCancellationToken.Noneを渡すと、そこでキャンセル経路が切れてしまいます。
// 悪い例:受け取ったトークンを無視しているstatic Task LoadAsync(CancellationToken cancellationToken){return Task.Delay(5000, CancellationToken.None);}次のように、受け取ったトークンを渡すべきです。
static Task LoadAsync(CancellationToken cancellationToken){return Task.Delay(5000, cancellationToken);}3. async・awaitを使った非同期処理をキャンセルする方法
3-1. Task.DelayにCancellationTokenを渡す基本例
Task.Delayには、CancellationTokenを受け取るオーバーロードがあります。
static async Task WaitAsync(CancellationToken cancellationToken){Console.WriteLine("待機を開始します。");await Task.Delay(TimeSpan.FromSeconds(30),cancellationToken);Console.WriteLine("待機が完了しました。");
}
遅延が終わる前にキャンセルされると、待機中のタスクはキャンセル状態となり、await時にキャンセル例外が発生します。
3-2. 非同期メソッドでCancellationTokenを受け取る実装
キャンセル可能な非同期メソッドは、次のように実装します。
static async Task<string> DownloadAsync(HttpClient httpClient,string url,CancellationToken cancellationToken){cancellationToken.ThrowIfCancellationRequested();using HttpResponseMessage response =await httpClient.GetAsync(url, cancellationToken);response.EnsureSuccessStatusCode();return await response.Content.ReadAsStringAsync(cancellationToken);
}
メソッドの最初にキャンセル状態を確認しておくと、すでにキャンセルされている場合に不要な初期化や通信を避けられます。
3-3. ループ処理を安全にキャンセルする方法
長時間ループでは、適切な間隔でキャンセル要求を確認します。
static async Task ProcessAsync(IReadOnlyList<string> files,CancellationToken cancellationToken){foreach (string file in files){cancellationToken.ThrowIfCancellationRequested(); await ProcessFileAsync(file, cancellationToken);}
}
CPU負荷の高いループでは、反復ごとに確認するとオーバーヘッドが気になることがあります。その場合は一定回数ごとに確認します。
static long Calculate(int count,CancellationToken cancellationToken){long total = 0;for (int i = 0; i < count; i++){if ((i & 1023) == 0){cancellationToken.ThrowIfCancellationRequested();}total += ExpensiveCalculation(i);}return total;
}
確認間隔を長くしすぎるとキャンセルへの反応が遅くなるため、処理コストとのバランスを取ります。
3-4. 複数の非同期処理に同じトークンを渡す方法
同じ処理単位に属する複数のタスクには、同じトークンを共有できます。
static async Task LoadAllAsync(HttpClient httpClient,CancellationToken cancellationToken){Task<string> usersTask =httpClient.GetStringAsync("/api/users", cancellationToken);Task<string> productsTask =httpClient.GetStringAsync("/api/products", cancellationToken);Task<string> settingsTask =httpClient.GetStringAsync("/api/settings", cancellationToken);await Task.WhenAll(usersTask, productsTask, settingsTask);
}
CancellationTokenは値型ですが、同じCancellationTokenSourceから取得したコピーは共通のキャンセル状態を参照します。1つのトークンを複数のタスクに渡すことは、一般的な使い方です。
3-5. Task.RunでCancellationTokenを使用する際の注意点
Task.RunにもCancellationTokenを渡せます。
Task<int> task = Task.Run(() => CalculateLargeData(cancellationToken),cancellationToken);ただし、Task.Runに渡したトークンだけで、実行開始後の処理が自動的に停止するわけではありません。
Task.Runのトークンは、主にタスクがまだ開始されていない場合のキャンセルに使用されます。処理が開始された後は、デリゲート内でもトークンを確認する必要があります。
static int CalculateLargeData(CancellationToken cancellationToken){int result = 0;for (int i = 0; i < 10_000_000; i++){cancellationToken.ThrowIfCancellationRequested();result += i % 100;}return result;
}
I/O中心の非同期処理を、理由なくTask.Runで包む必要はありません。HttpClientやFileStreamなどが提供する非同期APIへ直接トークンを渡す方が効率的です。
3-6. キャンセル要求が届かない処理への対処法
呼び出しているAPIがCancellationTokenに対応していない場合、外部から安全に強制停止することはできません。
対処法として、次の方法があります。
処理を小さな単位へ分割し、単位ごとにキャンセルを確認する
キャンセル対応のAPIへ置き換える
対象ライブラリ固有の中断機能を使用する
プロセスを分離し、必要に応じてプロセス単位で終了する
Task.WhenAnyで待機だけを打ち切る結果を受け取っても無視する
Task.WhenAnyで待機を終了しても、元の処理そのものが停止するとは限りません。バックグラウンドで処理が継続する可能性を考慮する必要があります。
4. キャンセル時の例外処理と正しい判定方法
4-1. OperationCanceledExceptionが発生する仕組み
OperationCanceledExceptionは、実行中の操作がキャンセルされたことを表す例外です。
次の処理では、キャンセル要求後にThrowIfCancellationRequestedが呼ばれると例外が発生します。
static async Task WorkAsync(CancellationToken cancellationToken){while (true){cancellationToken.ThrowIfCancellationRequested();await Task.Delay(100, cancellationToken);}}キャンセルは一般的な障害とは異なり、「呼び出し側の要求による予定された終了」である場合があります。そのため、ログ上でもエラーとキャンセルを分けて扱うことが重要です。
4-2. TaskCanceledExceptionとの違い
TaskCanceledExceptionは、タスクのキャンセルを通知するための例外で、OperationCanceledExceptionを継承しています。
Exception└─ SystemException└─ OperationCanceledException└─ TaskCanceledExceptionそのため、一般的なキャンセル処理では、TaskCanceledExceptionだけを捕捉するよりもOperationCanceledExceptionを捕捉する方が安全です。
try{await ExecuteAsync(cancellationToken);}catch (OperationCanceledException){Console.WriteLine("キャンセルされました。");}Task.DelayやHTTP通信ではTaskCanceledExceptionが見えることがありますが、実装が常にその派生型を投げるとは限りません。
4-3. try-catchでキャンセル例外を処理する方法
基本的な処理は次のとおりです。
using var cts = new CancellationTokenSource();try{await ExecuteAsync(cts.Token);}catch (OperationCanceledException)when (cts.IsCancellationRequested){Console.WriteLine("要求により処理をキャンセルしました。");}
例外フィルターを使用すると、自分が管理しているトークンのキャンセルだけを処理できます。
ライブラリ内部で後処理だけ行いたい場合は、処理後に再スローします。
try{await ExecuteCoreAsync(cancellationToken);}catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested){logger.LogInformation("処理がキャンセルされました。");throw;}4-4. 通常のエラーとユーザーによるキャンセルを区別する方法
キャンセル例外と通常の例外は、別々に処理します。
try{await DownloadAsync(cancellationToken);}catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested){ShowMessage("ダウンロードを中止しました。");}catch (HttpRequestException ex){ShowError($"通信に失敗しました: {ex.Message}");}catch (Exception ex){logger.LogError(ex, "予期しないエラーが発生しました。");throw;}catch (Exception)だけで処理すると、キャンセルが通常エラーとして記録される可能性があります。
なお、OperationCanceledExceptionはExceptionの派生型なので、一般例外のcatchより先に記述する必要があります。
4-5. キャンセルされたTaskのStatusを確認する方法
タスクがキャンセル状態で終了したかは、IsCanceledまたはStatusで確認できます。
Task task = ExecuteAsync(cancellationToken);try{await task;}catch (OperationCanceledException){Console.WriteLine(task.IsCanceled);Console.WriteLine(task.Status == TaskStatus.Canceled);}
TaskStatus.Canceledは、タスクがキャンセルを認識してキャンセル状態で完了したことを表します。未処理の通常例外で終了した場合はFaultedになります。
キャンセル要求が出されたとしても、処理が要求を無視して正常終了した場合、タスクはRanToCompletionになることがあります。
4-6. キャンセル例外を握りつぶしてはいけないケース
次のようにキャンセル例外を捕捉して、何もせず正常値を返す実装は注意が必要です。
static async Task<int> GetCountAsync(CancellationToken cancellationToken){try{return await LoadCountAsync(cancellationToken);}catch (OperationCanceledException){return 0;}}呼び出し側は、データが本当に0件だったのか、キャンセルされたのか区別できません。
ライブラリや下位レイヤーでは、原則としてキャンセル例外を再スローします。
catch (OperationCanceledException){Cleanup();throw;}キャンセルを正常終了へ変換する場合は、それがメソッドの契約として明確な場合に限ります。
5. タイムアウトをCancellationTokenで実装する方法
5-1. CancelAfterで一定時間後にキャンセルする
CancelAfterを使用すると、指定時間後にキャンセルを要求できます。
using var cts = new CancellationTokenSource();cts.CancelAfter(TimeSpan.FromSeconds(5));
try{await ExecuteAsync(cts.Token);}catch (OperationCanceledException)when (cts.IsCancellationRequested){Console.WriteLine("5秒でタイムアウトしました。");}
CancelAfterは処理を直接強制終了するものではありません。対象処理がトークンを監視している必要があります。
5-2. CancellationTokenSourceのコンストラクターで期限を指定する
作成時にタイムアウトを指定することもできます。
using var cts =new CancellationTokenSource(TimeSpan.FromSeconds(5));await ExecuteAsync(cts.Token);
コンストラクターが呼ばれた時点からカウントダウンが始まります。
処理開始前の準備に時間がかかる場合、その時間もタイムアウトに含まれるため、作成タイミングに注意してください。
5-3. HttpClientのリクエストをタイムアウトさせる
HttpClientの各メソッドには、CancellationTokenを受け取るオーバーロードがあります。
static async Task<string> DownloadWithTimeoutAsync(HttpClient httpClient,string url){using var timeoutCts =new CancellationTokenSource(TimeSpan.FromSeconds(10));using HttpResponseMessage response =await httpClient.GetAsync(url, timeoutCts.Token);response.EnsureSuccessStatusCode();return await response.Content.ReadAsStringAsync(timeoutCts.Token);
}
HTTP通信がキャンセルまたはタイムアウトした場合、OperationCanceledException系の例外が発生することがあります。ネットワーク障害は一般にHttpRequestExceptionとして扱われるため、両者を分けて処理します。
5-4. タイムアウトと手動キャンセルを区別する方法
手動キャンセルとタイムアウトを区別したい場合は、別々のCancellationTokenSourceを用意して連携させます。
static async Task ExecuteWithTimeoutAsync(CancellationToken userCancellationToken){using var timeoutCts =new CancellationTokenSource(TimeSpan.FromSeconds(10));using var linkedCts =CancellationTokenSource.CreateLinkedTokenSource(userCancellationToken,timeoutCts.Token);try{await ExecuteAsync(linkedCts.Token);}catch (OperationCanceledException)when (userCancellationToken.IsCancellationRequested){Console.WriteLine("ユーザーがキャンセルしました。");throw;}catch (OperationCanceledException)when (timeoutCts.IsCancellationRequested){throw new TimeoutException("処理が制限時間を超えました。");}
}
ほぼ同時に両方がキャンセルされた場合、両方のIsCancellationRequestedがtrueになる可能性があります。どちらを優先するかは、アプリケーションの仕様として決めておきます。
5-5. Task.WhenAnyによるタイムアウト実装との違い
Task.WhenAnyでも、一定時間だけ処理を待つ実装ができます。
static async Task<T> WaitWithTimeoutAsync<T>(Task<T> operationTask,TimeSpan timeout){Task delayTask = Task.Delay(timeout);Task completedTask =await Task.WhenAny(operationTask, delayTask);if (completedTask != operationTask){throw new TimeoutException();}return await operationTask;
}
ただし、このコードは待機を終了するだけで、operationTask自体をキャンセルしません。処理はバックグラウンドで継続する可能性があります。
処理そのものを止めたい場合は、対象処理へCancellationTokenを渡し、タイムアウト時にキャンセルを要求します。
6. 複数のCancellationTokenを連携させる方法
6-1. CreateLinkedTokenSourceの基本的な使い方
CreateLinkedTokenSourceを使用すると、複数のトークンのうち、いずれかがキャンセルされたときに連動するトークンを作成できます。
using var firstCts = new CancellationTokenSource();using var secondCts = new CancellationTokenSource();using var linkedCts =CancellationTokenSource.CreateLinkedTokenSource(firstCts.Token,secondCts.Token);
firstCts.Cancel();
Console.WriteLine(linkedCts.Token.IsCancellationRequested); // True
6-2. ユーザー操作とタイムアウトの両方に対応する
実務では、ユーザーのキャンセルとタイムアウトを組み合わせるケースがよくあります。
static async Task<string> SearchAsync(HttpClient httpClient,string keyword,CancellationToken userCancellationToken){using var timeoutCts =new CancellationTokenSource(TimeSpan.FromSeconds(15));using var linkedCts =CancellationTokenSource.CreateLinkedTokenSource(userCancellationToken,timeoutCts.Token);return await httpClient.GetStringAsync($"/api/search?q={Uri.EscapeDataString(keyword)}",linkedCts.Token);
}
ユーザー操作またはタイムアウトのどちらかが発生すると、HTTPリクエストへキャンセル要求が伝わります。
6-3. 親処理のキャンセルを子処理へ伝播させる
親処理から受け取ったトークンは、子処理へそのまま渡すのが基本です。
static async Task ImportAsync(CancellationToken cancellationToken){IReadOnlyList<string> files =await FindFilesAsync(cancellationToken);foreach (string file in files){cancellationToken.ThrowIfCancellationRequested();await ReadFileAsync(file, cancellationToken);await ParseFileAsync(file, cancellationToken);await SaveToDatabaseAsync(file, cancellationToken);}
}
子処理だけに独自のタイムアウトを追加する場合は、親トークンと子処理用トークンを連携させます。
using var childTimeoutCts =new CancellationTokenSource(TimeSpan.FromSeconds(3));using var linkedCts =CancellationTokenSource.CreateLinkedTokenSource(cancellationToken,childTimeoutCts.Token);
await ExecuteChildAsync(linkedCts.Token);
6-4. どのCancellationTokenがキャンセルされたか判定する
連携後のトークンだけでは、どの元トークンが原因だったかを直接判定できません。元のトークンを確認します。
try{await ExecuteAsync(linkedCts.Token);}catch (OperationCanceledException){if (userToken.IsCancellationRequested){Console.WriteLine("ユーザーによるキャンセル");}else if (timeoutCts.IsCancellationRequested){Console.WriteLine("タイムアウト");}throw;
}
複数の原因が同時に成立する可能性もあるため、厳密な監査が必要な場合は、キャンセルを要求した時点で原因を別途記録します。
6-5. LinkedTokenSourceをDisposeする必要性
リンクされたCancellationTokenSourceは、元のトークンへコールバックを登録して連携を実現します。
そのため、使用後はDisposeして登録を解除します。
using var linkedCts =CancellationTokenSource.CreateLinkedTokenSource(parentToken,timeoutToken);長期間動作するアプリケーションで破棄を怠ると、不要な登録や参照が残る原因になります。
7. CancellationTokenのコールバックを活用する方法
7-1. Registerでキャンセル時の処理を登録する
CancellationToken.Registerを使用すると、キャンセル時に実行する処理を登録できます。
using var cts = new CancellationTokenSource();using CancellationTokenRegistration registration =cts.Token.Register(() =>{Console.WriteLine("キャンセルが要求されました。");});
cts.Cancel();
主な用途は次のとおりです。
キャンセル非対応APIへ通知する
待機ハンドルを解放する
外部ライブラリの中断メソッドを呼ぶ
キャンセルをログへ記録する
7-2. 複数のコールバックを登録した場合の動作
1つのトークンには、複数のコールバックを登録できます。
cts.Token.Register(() => Console.WriteLine("1"));cts.Token.Register(() => Console.WriteLine("2"));cts.Token.Register(() => Console.WriteLine("3"));cts.Cancel();
通常、コールバックはCancelを呼び出したスレッド上で同期的に、後から登録されたものから実行されます。
ただし、実行順序に依存した設計は避けるべきです。各コールバックは、単独でも安全に動作するようにします。
7-3. CancellationTokenRegistrationを解除する方法
Registerの戻り値であるCancellationTokenRegistrationを破棄すると、登録を解除できます。
CancellationTokenRegistration registration =cancellationToken.Register(OnCanceled);try{await ExecuteAsync();}finally{registration.Dispose();}
通常はusingで管理します。
using CancellationTokenRegistration registration =cancellationToken.Register(OnCanceled);await ExecuteAsync();
処理終了後にコールバックが不要になる場合は、登録を残さないことが重要です。
7-4. コールバック内で例外を発生させる際の注意点
登録したコールバックが例外をスローすると、Cancelを呼び出した側へ例外が伝わる可能性があります。
cts.Token.Register(() =>{throw new InvalidOperationException("コールバック内のエラー");});cts.Cancel();
複数のコールバックが例外を投げた場合、CancelからAggregateExceptionが発生することがあります。
コールバック内では、可能な限り例外を外へ出さないようにします。
cts.Token.Register(() =>{try{NotifyExternalSystem();}catch (Exception ex){logger.LogError(ex,"キャンセル通知に失敗しました。");}});7-5. リソース解放処理へRegisterを使うべきか
一般的なリソース解放は、usingやfinallyで行う方が安全です。
await using FileStream stream = File.OpenRead(path);try{await ProcessStreamAsync(stream, cancellationToken);}finally{// usingにより確実に破棄される}
Registerは、キャンセルされた瞬間に外部待機を解除する必要がある場合などに適しています。
using CancellationTokenRegistration registration =cancellationToken.Register(() => legacyOperation.Abort());通常の破棄責任までコールバックに任せると、正常終了時とキャンセル時の管理が複雑になります。
8. 実践的なCancellationTokenの使用例
8-1. コンソールアプリの長時間処理をキー入力で停止する
次の例では、Ctrl+Cが押されたときに長時間処理をキャンセルします。
using var cts = new CancellationTokenSource();Console.CancelKeyPress += (_, eventArgs) =>{eventArgs.Cancel = true;cts.Cancel();};
try{for (int i = 1; i <= 1000; i++){cts.Token.ThrowIfCancellationRequested();
Console.WriteLine($"処理中: {i}");await Task.Delay(500, cts.Token);}
}catch (OperationCanceledException)when (cts.IsCancellationRequested){Console.WriteLine("キー入力により停止しました。");}
eventArgs.Cancel = trueを設定することで、Ctrl+Cによる即時プロセス終了を抑止し、後処理を実行できます。
8-2. ファイルの読み書きをキャンセルする
FileStreamの非同期読み書きやCopyToAsyncには、CancellationTokenを渡せます。
static async Task CopyFileAsync(string sourcePath,string destinationPath,CancellationToken cancellationToken){await using FileStream source = new(sourcePath,FileMode.Open,FileAccess.Read,FileShare.Read,bufferSize: 81920,useAsync: true);await using FileStream destination = new(destinationPath,FileMode.Create,FileAccess.Write,FileShare.None,bufferSize: 81920,useAsync: true);await source.CopyToAsync(destination,bufferSize: 81920,cancellationToken);
}
キャンセル時に出力先へ不完全なファイルが残る可能性があります。必要に応じて、一時ファイルへ出力し、正常完了後に正式な名前へ変更します。
8-3. HTTP通信をキャンセルする
static async Task<string> GetDataAsync(HttpClient httpClient,CancellationToken cancellationToken){using HttpResponseMessage response =await httpClient.GetAsync("https://example.com/api/data",HttpCompletionOption.ResponseHeadersRead,cancellationToken);response.EnsureSuccessStatusCode();return await response.Content.ReadAsStringAsync(cancellationToken);
}
大きなレスポンスでは、ヘッダー取得だけでなく、本文の読み取りにも同じトークンを渡します。
8-4. データベース処理をキャンセルする
ADO.NETの非同期実行メソッドには、CancellationTokenを受け取るものがあります。
static async Task<List<string>> LoadNamesAsync(DbConnection connection,CancellationToken cancellationToken){await connection.OpenAsync(cancellationToken);await using DbCommand command =connection.CreateCommand();command.CommandText ="SELECT Name FROM Customers ORDER BY Name";await using DbDataReader reader =await command.ExecuteReaderAsync(cancellationToken);var names = new List<string>();while (await reader.ReadAsync(cancellationToken)){names.Add(reader.GetString(0));}return names;
}
実際にどの程度迅速にキャンセルされるかは、使用するデータベースプロバイダーやサーバー側の対応状況によって異なります。
更新処理では、キャンセルによって一部だけ更新されないよう、必要に応じてトランザクションを使用します。
8-5. ASP.NET Coreでリクエスト中断を検知する
ASP.NET Coreでは、HttpContext.RequestAbortedからリクエスト中断を表すトークンを取得できます。
app.MapGet("/report",async (HttpContext context,ReportService reportService) =>{CancellationToken cancellationToken =context.RequestAborted; return await reportService.CreateAsync(cancellationToken);});</span></code></pre><p><span>Minimal APIでは、</span><code dir="ltr"><span>CancellationToken</span></code><span>をハンドラーの引数に直接記述することもできます。</span></p><pre dir="ltr"><code dir="ltr"><span>app.MapGet("/report",async (ReportService reportService,CancellationToken cancellationToken) =>{return await reportService.CreateAsync(cancellationToken);});</span></code></pre><p><span>ASP.NET CoreのMinimal APIでは、</span><code dir="ltr"><span>CancellationToken</span></code><span>などの特殊な型が自動的にバインドされます。</span></p><p><span>クライアントが切断した後も、データベースや外部APIへの問い合わせを続けないよう、処理の末端までトークンを渡します。</span></p><h3><span>8-6. BackgroundServiceの停止要求を処理する</span></h3><p><code dir="ltr"><span>BackgroundService.ExecuteAsync</span></code><span>には、ホストの停止要求を表す</span><code dir="ltr"><span>stoppingToken</span></code><span>が渡されます。</span></p><pre dir="ltr"><code dir="ltr"><span>public sealed class QueueWorker : BackgroundService
{private readonly ILogger<QueueWorker> _logger;
public QueueWorker(ILogger<QueueWorker> logger){_logger = logger;}protected override async Task ExecuteAsync(CancellationToken stoppingToken){while (!stoppingToken.IsCancellationRequested){try{await ProcessNextMessageAsync(stoppingToken);await Task.Delay(TimeSpan.FromSeconds(1),stoppingToken);}catch (OperationCanceledException)when (stoppingToken.IsCancellationRequested){break;}catch (Exception ex){_logger.LogError(ex,"メッセージ処理に失敗しました。");}}}
}
Task.Delayやメッセージ受信、データベース処理にもstoppingTokenを渡すことで、アプリケーション終了時に速やかに停止できます。
8-7. UIアプリのボタン操作で非同期処理をキャンセルする
WPFなどのUIアプリでは、処理開始時にCancellationTokenSourceを作成し、キャンセルボタンからCancelを呼び出します。
private CancellationTokenSource? _operationCts;private async void StartButton_Click(object sender,RoutedEventArgs e){_operationCts?.Cancel();_operationCts?.Dispose();
_operationCts = new CancellationTokenSource();try{StartButton.IsEnabled = false;await LoadDataAsync(_operationCts.Token);StatusText.Text = "完了しました。";}catch (OperationCanceledException)when (_operationCts.IsCancellationRequested){StatusText.Text = "キャンセルしました。";}finally{StartButton.IsEnabled = true;}
}
private void CancelButton_Click(object sender,RoutedEventArgs e){_operationCts?.Cancel();}
画面を閉じる際にも、実行中の処理をキャンセルしてからCancellationTokenSourceを破棄します。
9. CancellationTokenでよくある悩みと原因
9-1. Cancelを呼んでも処理が止まらない
主な原因は、処理側がキャンセル要求を確認していないことです。
static void BadLoop(CancellationToken cancellationToken){while (true){DoWork();}}次のように、ループ内で確認します。
static void GoodLoop(CancellationToken cancellationToken){while (true){cancellationToken.ThrowIfCancellationRequested();DoWork();}}呼び出しているライブラリがキャンセル非対応の場合も、Cancelだけでは停止しません。
9-2. IsCancellationRequestedが常にfalseになる
異なるCancellationTokenSourceのトークンを確認している可能性があります。
using var firstCts = new CancellationTokenSource();using var secondCts = new CancellationTokenSource();CancellationToken token = firstCts.Token;
secondCts.Cancel();
Console.WriteLine(token.IsCancellationRequested); // False
Cancelしたソースと、処理へ渡したトークンの関係を確認してください。
メソッド内部で新しいCancellationTokenSourceを作り、呼び出し元のトークンを無視している場合も同じ問題が起こります。
9-3. OperationCanceledExceptionが予期せず発生する
Task.Delay、HTTP通信、ファイル操作などへトークンを渡すと、キャンセル時にOperationCanceledException系の例外が発生します。
これは異常な動作ではなく、キャンセルを呼び出し側へ通知する標準的な方法です。
try{await Task.Delay(5000, cancellationToken);}catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested){// 想定されたキャンセル}トークンを渡した処理は、キャンセル例外が発生する可能性を前提に設計します。
9-4. CancellationTokenを渡しているのに即座に停止しない
キャンセル要求が確認されるタイミングは、処理によって異なります。
たとえば、次のような処理は反応が遅くなります。
長時間の同期I/O
1回の計算に数秒かかるループ
キャンセル非対応の外部ライブラリ
非常に長いポーリング間隔
サーバー側でキャンセル処理に時間がかかる通信
処理を小さな単位へ分割し、適切な間隔でトークンを確認します。
9-5. キャンセル後に同じCancellationTokenSourceを再利用できない
一度キャンセルされたCancellationTokenSourceは、キャンセル状態を維持します。
cts.Cancel();Console.WriteLine(cts.Token.IsCancellationRequested); // True
通常は、処理ごとに新しいインスタンスを作成します。
_operationCts?.Dispose();_operationCts = new CancellationTokenSource();新しい.NETにはTryResetもありますが、処理が完全に終了し、他のコードがCancelを呼ばず、残った登録も不要だと所有者が保証できる場合を想定した機能です。一般的なアプリケーションコードでは、新しいインスタンスを作る方が安全です。
9-6. Dispose後のCancellationTokenSourceを操作してしまう
破棄後のCancellationTokenSourceに対して、Cancelなどの操作を行うとObjectDisposedExceptionが発生する場合があります。
特に、複数スレッドからCancelとDisposeを実行する設計は競合しやすくなります。
所有者を明確にし、次の順序を基本とします。
実行中の処理へキャンセルを要求する
処理の終了を待つ
CancellationTokenSourceを破棄する
9-7. 非同期処理の一部だけがキャンセルされない
処理の途中でトークンを渡し忘れている可能性があります。
static async Task ExecuteAsync(CancellationToken cancellationToken){await LoadAsync(cancellationToken);// トークンを渡していないawait SaveAsync();
}
次のように末端まで伝播させます。
static async Task ExecuteAsync(CancellationToken cancellationToken){await LoadAsync(cancellationToken);await SaveAsync(cancellationToken);}CancellationToken.Noneや引数なしのオーバーロードを使用している場所も確認します。
9-8. キャンセルとタイムアウトを判別できない
同じCancellationTokenSourceを手動キャンセルとタイムアウトの両方に使うと、原因を判別しにくくなります。
手動キャンセル用とタイムアウト用を分離し、CreateLinkedTokenSourceで連携させます。
using var timeoutCts =new CancellationTokenSource(timeout);using var linkedCts =CancellationTokenSource.CreateLinkedTokenSource(userToken,timeoutCts.Token);
例外発生時には、元のトークンの状態を確認します。
10. CancellationTokenを安全に使うための設計指針
10-1. CancellationTokenをメソッドの最後の引数にする
.NETでは、CancellationTokenをメソッドの最後の引数に配置する設計が一般的です。
Task<User> FindUserAsync(int userId,bool includeDetails,CancellationToken cancellationToken);オプション引数として公開する場合も、最後に配置すると呼び出しやすくなります。
Task<User> FindUserAsync(int userId,CancellationToken cancellationToken = default);10-2. 公開メソッドでは省略可能、内部メソッドでは必須にする
公開APIでは、互換性や使いやすさのために省略可能にする方法があります。
public Task<Data> LoadAsync(CancellationToken cancellationToken = default){return LoadCoreAsync(cancellationToken);}内部メソッドでは必須引数にすることで、トークンの渡し忘れを防ぎます。
private Task<Data> LoadCoreAsync(CancellationToken cancellationToken){// 実装}ただし、キャンセルが必須の業務要件である場合は、公開メソッドでも必須にします。
10-3. CancellationTokenを処理の末端まで伝播させる
受け取ったトークンは、HTTP、データベース、ファイル、待機処理など、キャンセル可能なすべての下位処理へ渡します。
public async Task<Order> CreateOrderAsync(OrderRequest request,CancellationToken cancellationToken){Customer customer =await repository.FindCustomerAsync(request.CustomerId,cancellationToken);Price price =await pricingClient.CalculateAsync(request,cancellationToken);return await repository.SaveOrderAsync(customer,price,cancellationToken);
}
途中でトークンを渡さない層があると、その地点から先はキャンセルできません。
10-4. キャンセル不能になる地点を明確にする
業務処理には、途中でキャンセルすると危険な地点があります。
たとえば、決済確定後やデータのコミット中に単純に処理を終了すると、状態が不明確になる可能性があります。
cancellationToken.ThrowIfCancellationRequested();await paymentService.AuthorizeAsync(payment,cancellationToken);
// ここからは確定処理。// 呼び出し元のキャンセルで中断しない。await CommitOrderAsync(order,CancellationToken.None);
キャンセル不能にする場合は、理由をコメントや設計資料へ明記します。キャンセルを無視するのではなく、「どの地点まではキャンセル可能か」を意図的に決めることが重要です。
10-5. データ更新中のキャンセルで不整合を起こさない
複数の更新を行う途中でキャンセルされると、データが中途半端な状態になる可能性があります。
対策として、次の方法を検討します。
データベーストランザクションを使う
一時データへ書き込み、完了後に切り替える
処理を冪等にする
再実行可能な状態を保存する
補償処理を用意する
コミット前だけキャンセルを受け付ける
キャンセル可能性は、単なるUI機能ではなく、データ整合性の設計にも影響します。
10-6. CancellationTokenSourceの所有者とDispose責任を決める
一般に、CancellationTokenSourceを作成したコードが、そのソースの所有者です。
所有者は次の責任を持ちます。
キャンセルを要求するタイミングを決める
実行中処理の終了を管理する
不要になったソースを破棄する
他のコードと
CancelやDisposeを競合させない
下位メソッドにはソースそのものではなく、CancellationTokenだけを渡します。
10-7. ポーリング間隔とパフォーマンスのバランスを取る
キャンセル確認の間隔が短いほど反応は速くなりますが、非常に軽い処理では確認コストが無視できない場合があります。
目安としては、次のように考えます。
I/O処理:対応APIへ毎回トークンを渡す
重い反復処理:各反復または数百~数千回ごとに確認する
長い待機:
Task.Delayへトークンを渡す1回の処理が長い:処理自体を分割する
実測せずにポーリング回数を極端に減らすと、キャンセルへの応答性が悪化します。
10-8. CancellationTokenをフィールドに保持する際の注意点
CancellationTokenをフィールドに保持すると、異なる処理のトークンを誤って共有する可能性があります。
private CancellationToken _token;可能であれば、メソッド引数として渡します。
UI処理などでフィールドが必要な場合は、トークンではなく操作単位のCancellationTokenSourceを管理し、同時実行や破棄のルールを決めます。
private CancellationTokenSource? _currentOperationCts;新しい処理を開始するときは、古い処理のキャンセルと終了を適切に管理してください。
11. CancellationToken実装時のアンチパターン
11-1. CancellationTokenSourceを毎回メソッド内で勝手に生成する
下位メソッドが独自にソースを作成すると、呼び出し元からキャンセルできません。
static async Task BadAsync(){using var cts = new CancellationTokenSource();await Task.Delay(5000, cts.Token);}外部からトークンを受け取る設計にします。
static Task GoodAsync(CancellationToken cancellationToken){return Task.Delay(5000, cancellationToken);}独自タイムアウトが必要な場合は、呼び出し元のトークンと連携させます。
11-2. catch Exceptionでキャンセル例外を通常エラーとして扱う
次の実装では、キャンセルまでシステム障害として記録されます。
try{await ExecuteAsync(cancellationToken);}catch (Exception ex){logger.LogError(ex, "処理に失敗しました。");}キャンセルを先に分けます。
try{await ExecuteAsync(cancellationToken);}catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested){logger.LogInformation("処理がキャンセルされました。");throw;}catch (Exception ex){logger.LogError(ex, "処理に失敗しました。");throw;}11-3. キャンセル要求を確認せず長時間ループを続ける
トークンを引数で受け取るだけでは意味がありません。
static void Process(CancellationToken cancellationToken){for (int i = 0; i < int.MaxValue; i++){Calculate(i);}}ループ内で確認します。
static void Process(CancellationToken cancellationToken){for (int i = 0; i < int.MaxValue; i++){cancellationToken.ThrowIfCancellationRequested();Calculate(i);}}11-4. ライブラリへCancellationTokenを渡さずキャンセル経路を切る
受け取ったトークンを無視して、引数なしのオーバーロードを呼ぶのは典型的な問題です。
await httpClient.GetAsync(url);await command.ExecuteReaderAsync();await Task.Delay(1000);次のように渡します。
await httpClient.GetAsync(url, cancellationToken);await command.ExecuteReaderAsync(cancellationToken);await Task.Delay(1000, cancellationToken);11-5. キャンセル時に中途半端な結果を正常値として返す
キャンセル時に途中までの結果を正常値として返すと、呼び出し側が完全な結果だと誤認する可能性があります。
catch (OperationCanceledException){return partiallyLoadedItems;}部分結果を返す必要がある場合は、結果型に状態を含めます。
public sealed record LoadResult(IReadOnlyList<Item> Items,bool IsComplete);あるいは、キャンセル例外をそのまま伝播させます。
11-6. CancelとDisposeを同時実行して競合を起こす
あるスレッドがCancelを実行している間に、別のスレッドが同じソースをDisposeすると、予期しない例外や競合の原因になります。
ライフサイクルを1つの所有者へ集約し、次の順序を守ります。
cts.Cancel();try{await runningTask;}catch (OperationCanceledException){}
cts.Dispose();
共有フィールドを使用する場合は、ロックや交換処理を含めて所有権を慎重に管理します。
12. CancellationTokenのテスト方法
12-1. 事前にキャンセルされたトークンでテストする
キャンセル済みのトークンを渡すと、処理開始前の挙動を確認できます。
[Fact]public async Task ExecuteAsync_PreCanceled_Throws(){using var cts = new CancellationTokenSource();cts.Cancel();await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ExecuteAsync(cts.Token));
}
メソッド冒頭でキャンセルを確認していれば、外部通信などが始まる前に終了できます。
12-2. CancelAfterを使ってキャンセル動作を確認する
簡単なテストではCancelAfterを使用できます。
[Fact]public async Task ExecuteAsync_IsCanceled(){using var cts = new CancellationTokenSource();cts.CancelAfter(TimeSpan.FromMilliseconds(100));await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ExecuteAsync(cts.Token));
}
ただし、短すぎるタイムアウトに依存したテストは、実行環境の負荷によって不安定になることがあります。
12-3. OperationCanceledExceptionを検証する
キャンセル時に単に例外が出ることだけでなく、通常エラーへ変換されていないことを確認します。
[Fact]public async Task ExecuteAsync_ThrowsCancellation(){using var cts = new CancellationTokenSource();cts.Cancel();OperationCanceledException exception =await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ExecuteAsync(cts.Token));Assert.Equal(cts.Token,exception.CancellationToken);
}
実装や使用APIによっては、例外に設定されるトークンの扱いが異なることがあります。テスト対象の契約に応じて検証範囲を決めます。
12-4. キャンセル時にリソースが解放されるか確認する
キャンセル後もストリーム、接続、登録などが残らないことを確認します。
[Fact]public async Task ExecuteAsync_DisposesResource_WhenCanceled(){var resource = new FakeDisposableResource();using var cts = new CancellationTokenSource();Task task = service.ExecuteAsync(resource,cts.Token);await resource.Started;cts.Cancel();await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);Assert.True(resource.IsDisposed);
}
モックやテスト用リソースを使い、DisposeやDisposeAsyncが呼ばれたことを検証します。
12-5. キャンセルと正常終了が競合するケースをテストする
キャンセル要求と処理完了がほぼ同時に発生すると、結果はタイミングによって変わる可能性があります。
確認すべき点は次のとおりです。
正常終了した場合に結果が壊れていない
キャンセルされた場合に部分更新が残らない
例外が未監視にならない
リソースが二重に破棄されない
コールバックが終了後に不正な状態を操作しない
どちらの結果になっても整合性が保たれることを重視します。
12-6. タイムアウトに依存しない安定したテストを書く方法
時間待ちではなく、TaskCompletionSourceなどを使って処理の到達地点を同期します。
[Fact]public async Task ExecuteAsync_CancelsAfterStarted(){var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);using var cts = new CancellationTokenSource();Task task = service.ExecuteAsync(started,cts.Token);await started.Task;cts.Cancel();await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
}
この方法なら、「処理が開始したことを確認してからキャンセルする」という順序を確実に作れます。
テスト内のTask.Delayを減らすことで、処理速度やマシン負荷に左右されにくいテストになります。
13. CancellationTokenに関するよくある質問
13-1. CancellationTokenだけで実行中のスレッドを強制終了できる?
できません。
CancellationTokenは協調的キャンセルの仕組みです。処理側がトークンを確認し、自ら終了する必要があります。
スレッドや処理を外部から突然停止すると、ロックの未解放、データ破損、リソースリークなどを起こす可能性があります。そのため、安全な終了地点を処理側で決めます。
13-2. CancellationTokenSourceは再利用できる?
一度キャンセルしたソースは、原則として再利用せず、新しいCancellationTokenSourceを作成します。
新しい.NETにはTryResetがありますが、安全に再利用できることを単独の所有者が保証できる限定的な場面向けです。通常の業務コードでは、新規作成の方が理解しやすく安全です。
13-3. CancellationTokenSourceは必ずDisposeすべき?
使用後は、所有者がDisposeする設計が基本です。
特に、次の機能を使用した場合は適切に破棄します。
CancelAfter期限付きコンストラクター
CreateLinkedTokenSourceコールバック登録
待機ハンドル
ただし、処理中に別スレッドから破棄しないよう、ライフサイクルを管理する必要があります。
13-4. CancellationTokenは複数のTaskで共有できる?
共有できます。
同じ処理単位に属する複数のタスクへ、同じトークンを渡すのは一般的です。
await Task.WhenAll(LoadUsersAsync(token),LoadProductsAsync(token),LoadSettingsAsync(token));1回のCancelで、すべての処理へキャンセル要求を通知できます。
13-5. CancellationToken.Noneとdefaultの違いは?
一般的な利用では、どちらもキャンセル不能なトークンとして扱えます。
CancellationToken first =CancellationToken.None;CancellationToken second = default;
Console.WriteLine(first.CanBeCanceled); // FalseConsole.WriteLine(second.CanBeCanceled); // False
APIの既定引数では、次のようにdefaultがよく使われます。
Task ExecuteAsync(CancellationToken cancellationToken = default);コード上でキャンセルしない意図を明確に示したい場合は、CancellationToken.Noneも分かりやすい表現です。
13-6. ThrowIfCancellationRequestedとreturnはどう使い分ける?
呼び出し側へキャンセルとして通知したい場合は、ThrowIfCancellationRequestedを使います。
cancellationToken.ThrowIfCancellationRequested();単に処理を終了し、正常終了として扱ってよい場合はreturnを使えます。
if (cancellationToken.IsCancellationRequested){return;}ライブラリや戻り値を持つ処理では、正常結果とキャンセルを明確に区別できるThrowIfCancellationRequestedが適しています。
13-7. キャンセル後も後処理を実行するには?
finallyを使用します。
try{await ExecuteAsync(cancellationToken);}finally{await CleanupAsync(CancellationToken.None);}後処理にも元のキャンセルトークンを渡すと、すでにキャンセル済みのため、後処理が即座に中断される可能性があります。
必ず完了させる必要がある短い後処理では、CancellationToken.Noneや別の期限付きトークンを検討します。
ただし、終了不能な後処理にしないよう、独自の短いタイムアウトを設ける方法も有効です。
13-8. 同期メソッドでもCancellationTokenを使える?
使用できます。
CancellationTokenは非同期処理専用ではありません。長時間の同期計算やループでも利用できます。
static int Calculate(IReadOnlyList<int> values,CancellationToken cancellationToken){int result = 0;foreach (int value in values){cancellationToken.ThrowIfCancellationRequested();result += ExpensiveCalculation(value);}return result;
}
同期処理では、処理側が定期的に状態を確認することが特に重要です。
まとめ
C#のCancellationTokenは、非同期処理や長時間処理を安全に終了させるための協調的キャンセル機能です。
実装の基本は、CancellationTokenSourceを作成し、そのTokenを処理の末端まで渡し、適切な地点でキャンセル要求を確認することです。
Cancelを呼び出しただけでは、処理は自動的に停止しません。Task.Delay、HttpClient、ファイル操作、データベース処理などのキャンセル対応APIへトークンを渡し、独自ループではThrowIfCancellationRequestedを使用する必要があります。
また、実務では次の点が重要です。
キャンセルと通常エラーを区別する
手動キャンセルとタイムアウトを別々に管理する
複数のトークンは
CreateLinkedTokenSourceで連携するデータ更新中のキャンセルで不整合を起こさない
CancellationTokenSourceの所有者と破棄責任を明確にするキャンセル不能な処理へ入る地点を設計する
時間依存を減らした安定したテストを書く
CancellationTokenを単に引数へ追加するだけでなく、キャンセル要求がどこまで伝わり、どの地点で処理が終了するかを設計することで、応答性が高く、安全で保守しやすいC#アプリケーションを実装できます。

