連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
落ちない。例外も出ているわけでもない。それでも処理が返ってこない。待ち続けて画面が白くなる。
外部I/O、DB、ロック待ち――どこかで「待ち」が継続する。
このページは、そんなタイムアウト(待つ上限)とキャンセル(止める要求)を決めどころのルールとして揃え、Task.WhenAnyとtoken伝播で「待ち」と「停止」を扱える形へ寄せる。
1. このページで手に入るもの(最短)
結論: 「返ってこない(無限待ち)」「止まらない」「秒数の責任が曖昧」を同じ型へ寄せ、調査と運用を短くする。
- コピペで使える:
TimeoutUtil.WithTimeout/CancelOnTimeout.Run(用途コメント+★OK/★NG付き) -
ctの正体と、停止要求が流れる経路が分かる(検索で止まらない) -
Task.WhenAnyは「待ち」を抜けるだけ、処理停止はtoken監視が前提だと分かる - timeout秒数(
TimeSpan)を決める置き場が分かる(下層へ埋めない) - WinForms/外部API/ループの混入点を判例で潰せる
- レビューで見る観点が1表に揃う(重複は折りたたみへ移動)
2. 先に逆引き(症状→原因→対策)
狙い: スクロールだけで「どこを見るか」「最短で何を入れるか」が決まる状態に寄せる。
| 症状 | ありがちな原因 | 切り分け(見る場所) | 最短の対処 | 再発防止(ルール化) |
|---|---|---|---|---|
| 返ってこないが落ちない | 無限待ち | 外部I/O呼び出し直前 / ロック待ち直前 |
WhenAnyで待ちに上限 |
秒数を一覧化 |
| キャンセルが効かない | token未伝播 / 未監視 | シグネチャ / ループ / 待機 | tokenを末端まで渡す | token必須の規約化 |
| タスクが溜まる | 待ちだけ抜けて処理が残る |
WhenAny利用箇所 |
連結token + CancelAfter
|
timeoutで停止要求も流す |
| タイムアウトの説明ができない | 秒数の責任が曖昧 | UI起動点/ジョブ起動/設定読み込み | 秒数を起動点で決める | 秒数の表を持つ |
3. 用語メモ(先に1分)
結論: ct と「決めどころ」の意味を最初に揃えると、途中で止まりにくい。
3-1. 決めどころとは何か
狙い: 「どこで何を決める話か」がぼんやりして止まる状態を消す。
このページでの決めどころは、次のような「方針を決める場所」を指す。
- UIイベントハンドラ(ボタン押下、メニュー実行などの起動点)
- 外部I/O呼び出し直前(HTTP/DB/ファイル/IPC/ロック待ちに入る直前)
- ジョブ起動点(バッチ、バックグラウンド処理の起動)
- 設定読み込み直後(timeout秒数を一覧化できる場所)
ここで決めるものは主に2つ。
-
timeout秒数(
TimeSpan) -
停止要求の流し方(
CancellationTokenの渡し方)
3-2. ct は何か(検索で止まりやすい所)
狙い: ct の正体が分からず止まる状態を消す。
-
CancellationTokenは「止める要求」を運ぶための値(構造体) - 現場のコードでは
CancellationToken ctのように ct が省略名として使われることが多い- ct = cancellation token の短縮
- 発火点は
CancellationTokenSource-
source.Cancel()で停止要求を出す -
source.Tokenを取り出し、下へ渡す
-
このページのテンプレは、引数名を cancellationToken に寄せる。
省略名 ct のコードも、同じ意味として読み替えられる。
3-3. Task.WhenAny / CancelAfter は何か
狙い: API名だけが並んで止まる状態を消す。
-
Task.WhenAny(a, b): 先に完了したタスクを返す(勝ち/負けを分岐できる) -
CancellationTokenSource.CancelAfter(timeout): timeout後に停止要求を発火する(token経路へ乗せる)
3-4. タイムアウト/キャンセルの違い(例外の扱いが分かれる)
狙い: 例外とログが混ざって運用が長引く形を減らす。
- タイムアウト: 待つ上限(仕様) →
TimeoutExceptionに寄せる - キャンセル: 止める要求(制御) →
OperationCanceledExceptionに寄せる
4. 最短テンプレ(コピペ)
狙い: まず貼れる形を置き、用途・置き場・例外方針が1回で分かる状態に寄せる。
4-1. Task.WhenAnyで待ちに上限を付ける(待ちの制御)
結論: WhenAnyは「待ち」を抜ける。停止要求まで保証しない(止めるなら4-2へ)。
using System;
using System.Threading;
using System.Threading.Tasks;
public static class TimeoutUtil
{
// 用途: 「待ち」に上限を付ける(待ちの制御)
// 置き場: 外部I/O呼び出し直前 / ロック待ちに入る直前 / UI起動点の直後
// 例外方針:
// - キャンセル: OperationCanceledException
// - タイムアウト: TimeoutException
public static async Task<T> WithTimeout<T>(
Task<T> task,
TimeSpan timeout,
CancellationToken cancellationToken)
{
// ★OK: Delayもtoken対応にし、キャンセルで「待ち」も解除できる形へ寄せる
var delay = Task.Delay(timeout, cancellationToken);
// ★OK: WhenAnyは「先に終わった方」を返すだけ(負け側は自動停止しない)
var completed = await Task.WhenAny(task, delay).ConfigureAwait(false);
if (completed == task)
{
// ★OK: task本体の例外も含め、結果を正しく表に出す
return await task.ConfigureAwait(false);
}
// ★OK: Delay側が勝った => キャンセルかタイムアウトかを区別する
cancellationToken.ThrowIfCancellationRequested();
// ★OK: タイムアウトはTimeoutExceptionへ寄せる
throw new TimeoutException($"Timeout after {timeout}.");
}
public static async Task WithTimeout(
Task task,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var delay = Task.Delay(timeout, cancellationToken);
var completed = await Task.WhenAny(task, delay).ConfigureAwait(false);
if (completed == task)
{
await task.ConfigureAwait(false);
return;
}
cancellationToken.ThrowIfCancellationRequested();
throw new TimeoutException($"Timeout after {timeout}.");
}
}
4-2. timeoutで停止要求も流す(連結token + CancelAfter)
結論: timeoutで「待ち」だけ抜けると処理が残りやすい。timeout発火で停止要求も流す。
using System;
using System.Threading;
using System.Threading.Tasks;
public static class CancelOnTimeout
{
// 用途: timeoutを超えたら「止める要求」も流す(処理停止の制御)
// 置き場: 外部I/O呼び出し直前 / 重い処理の呼び出し直前
// 例外方針:
// - 呼び出し元token: OperationCanceledException
// - timeout: TimeoutException
public static async Task<T> Run<T>(
Func<CancellationToken, Task<T>> action,
TimeSpan timeout,
CancellationToken cancellationToken)
{
// ★OK: 呼び出し元キャンセルとtimeoutを同じtoken経路へ乗せる
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// ★OK: timeout経過で停止要求を発火(token経路に流す)
linked.CancelAfter(timeout);
try
{
// ★OK: linked.Tokenを末端へ渡し、止める要求が届く形へ寄せる
return await action(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (
linked.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// ★OK: timeout発火(=CancelAfter)をタイムアウト扱いへ寄せる
throw new TimeoutException($"Timeout after {timeout}.");
}
}
}
4-3. timeout秒数は起動点で決める(下層へ埋めない)
結論: 秒数は仕様。起動点(UI/ジョブ)か設定読み込み直後で決め、下層へ散らさない。
using System;
using System.Threading;
using System.Threading.Tasks;
public sealed class UseCase
{
private readonly ApiClient _api;
public UseCase(ApiClient api) => _api = api;
public async Task<string> LoadDataAsync(string endpoint, CancellationToken cancellationToken)
{
// ★OK: 仕様(秒数)は起動点/設定側で決める。ここは受け取り側の例
// - UI操作は短め、バッチは長め、などが説明できる形になる
var timeout = TimeSpan.FromSeconds(10);
// ★OK: timeout時に停止要求も流す形へ寄せる
return await CancelOnTimeout.Run(
(t) => _api.GetStringAsync(endpoint, t),
timeout,
cancellationToken).ConfigureAwait(false);
}
}
5. 解説:評価規則(揃っていれば良い形)
結論: 3点が揃うと、無限待ちと停止不能の混入が減る。
- timeout秒数を起動点で決める(秒数は仕様。下層へ埋めない)
- CancellationTokenを末端まで渡す(止められない処理を増やさない)
- timeout/cancelをログで区別できる(何を待っていたかが残る)
6. 解説:型の変化(token伝播 → WhenAny → CancelAfter)
結論: 「止められる前提」→「待ちに上限」→「timeoutで停止要求も流す」の順に積むと強い。
6-1. 型1: tokenを末端まで流す(止められる前提)
狙い: tokenが途中で消える形を減らす。
using System.Threading;
using System.Threading.Tasks;
public Task<string> FetchAsync(string key, CancellationToken cancellationToken)
{
// ★OK: 引数で受け取ったtokenは、そのまま下へ渡す
// - tokenを握らず、途中で消さない
return FetchCoreAsync(key, cancellationToken);
}
6-2. 型2: WhenAnyで待ちに上限(待ちの制御)
狙い: 無限待ちを減らし、timeout/cancelを区別できる形へ寄せる。
ポイント:
-
Task.Delay(timeout, token)にすると、キャンセルで待ちも解除できる -
TimeoutExceptionとOperationCanceledExceptionを分けられる
注意:
-
WhenAnyは「待ち」を抜けるだけ
停止要求まで揃えるなら、対象処理がtokenを見ている前提が要る(型3へ)
6-3. 型3: 連結token + CancelAfter(停止要求も流す)
狙い: timeout時に処理が残り続ける形を減らす。
ポイント:
-
CreateLinkedTokenSource(token)でキャンセルとtimeoutを同じ経路へ -
CancelAfter(timeout)でtimeout発火時に停止要求を流す - timeoutは
TimeoutExceptionへ寄せ、ログと通知条件が揃う
7. 落とし穴(詰まりやすい形)
結論: ここが残ると「返ってこない」「止まらない」が再発しやすい。
-
WhenAnyで待ちだけ抜け、処理が残る(対象処理がtokenを見ていない) -
Task.Delay(timeout)(token無し)でキャンセルでも待ちが解除されない - ループはtokenを渡すだけでは止まらない(ループ内監視が必要)
- 秒数が下層へ散らばり、説明と調査が伸びる(起動点で決める)
8. 判例:混入点が多い順に潰す
結論: UI起動点 / 外部I/O / WhenAny / ループ / 秒数の置き場 / ログ、の順に揃えると再発が減る。
8-1. UI(WinForms): 起動点で前回を止め、例外を分ける
狙い: 起動点で停止要求の流れを揃え、timeout/cancel/想定外の扱いを分離する。
悪い例(起動が積み重なり、扱いが混ざる):
using System;
using System.Threading;
using System.Threading.Tasks;
private CancellationTokenSource? _cts;
private async void btnStart_Click(object sender, EventArgs e)
{
// ★NG: 起動点で前回を止めていない(同時実行が積み上がる)
_cts = new CancellationTokenSource();
try
{
var endpoint = "<endpoint>";
// ★NG: tokenを渡していない(停止要求が届かない)
// ★NG: timeout秒数も決まっていない(待ちが無限に伸びやすい)
var result = await LoadDataAsync(endpoint, CancellationToken.None);
// UI更新
}
catch (Exception)
{
// ★NG: timeout/cancel/想定外が混ざり、扱いが崩れる
}
}
直す(起動点で前回を止め、例外を分ける):
using System;
using System.Threading;
using System.Threading.Tasks;
private CancellationTokenSource? _cts;
private async void btnStart_Click(object sender, EventArgs e)
{
// ★OK: 起動点で前回を止める(同時実行の積み上がりを減らす)
_cts?.Cancel();
_cts = new CancellationTokenSource();
try
{
btnStart.Enabled = false;
btnCancel.Enabled = true;
var endpoint = "<endpoint>";
// ★OK: tokenを渡し、停止要求が届く形へ寄せる
// - UIはawait後にUIスレッドへ戻る前提が多いのでConfigureAwait(false)は避ける
var result = await LoadDataAsync(endpoint, _cts.Token);
// UI更新(成功時のみ)
}
catch (OperationCanceledException)
{
// ★OK: 想定内(操作キャンセル等)
}
catch (TimeoutException)
{
// ★OK: 運用対象(ログと通知、timeout値/対象/opが残る形へ寄せる)
}
catch (Exception)
{
// ★OK: 想定外(例外ログの対象)
throw;
}
finally
{
btnStart.Enabled = true;
btnCancel.Enabled = false;
}
}
private void btnCancel_Click(object sender, EventArgs e)
{
// ★OK: 起動点側の停止要求
_cts?.Cancel();
}
ポイント:
-
async voidはUIイベントに限定 -
OperationCanceledExceptionとTimeoutExceptionを分ける - timeout秒数は呼び出し側で決める(8-5)
8-2. 外部API: token非対応呼び出しが混ざる
狙い: 外部I/Oで停止要求が届かない形を減らす。
悪い例(停止要求が届かない):
using System.Net.Http;
using System.Threading.Tasks;
public sealed class ApiClient
{
private readonly HttpClient _http;
public ApiClient(HttpClient http) => _http = http;
public async Task<string> GetStringAsync(string endpoint)
{
// ★NG: token無し(停止要求が届かない)
using var resp = await _http.GetAsync(endpoint).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
}
}
直す(tokenを引数で受け取り、下へ渡す):
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
public sealed class ApiClient
{
private readonly HttpClient _http;
public ApiClient(HttpClient http) => _http = http;
public async Task<string> GetStringAsync(string endpoint, CancellationToken cancellationToken)
{
// ★OK: token対応APIへ寄せる(停止要求が届く)
using var resp = await _http.GetAsync(endpoint, cancellationToken).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
}
ポイント:
- tokenを引数で受け取る形へ寄せる(途中で消えない)
-
Task.Runで逃げる前に、token対応APIを使う
8-3. WhenAny: Delayがtoken無しでキャンセルしても待つ
狙い: 「止めたのに待つ」を減らし、キャンセルで待ちが解除される形へ寄せる。
悪い例(Delayがtoken無し):
using System;
using System.Threading;
using System.Threading.Tasks;
public static async Task WithTimeout_Bad(Task task, TimeSpan timeout, CancellationToken cancellationToken)
{
// ★NG: Delayがtoken無し(キャンセルしても待ちが解除されない)
var delay = Task.Delay(timeout);
var completed = await Task.WhenAny(task, delay).ConfigureAwait(false);
if (completed == task)
{
await task.ConfigureAwait(false);
return;
}
// ★NG: cancel/timeoutの区別が崩れやすい
throw new TimeoutException($"Timeout after {timeout}.");
}
直す(Delayもtoken対応にし、timeout/cancelを区別する):
using System;
using System.Threading;
using System.Threading.Tasks;
public static async Task WithTimeout_Good(Task task, TimeSpan timeout, CancellationToken cancellationToken)
{
// ★OK: Delayもtoken対応(キャンセルで待ちが解除される)
var delay = Task.Delay(timeout, cancellationToken);
var completed = await Task.WhenAny(task, delay).ConfigureAwait(false);
if (completed == task)
{
await task.ConfigureAwait(false);
return;
}
// ★OK: cancel/timeoutを区別
cancellationToken.ThrowIfCancellationRequested();
throw new TimeoutException($"Timeout after {timeout}.");
}
ポイント:
-
Task.Delay(timeout, token)でキャンセル時に待ちも解除できる -
ThrowIfCancellationRequested()で区別が明確になる
8-4. 重要: ループはtokenを見ないと止まらない
狙い: tokenを渡すだけで止まると思い込みやすい所を潰す。
悪い例(tokenを見ていない):
using System.Threading;
using System.Threading.Tasks;
public async Task PollAsync_Bad(CancellationToken cancellationToken)
{
while (true)
{
// ★NG: tokenを見ていない(停止要求が来ても続く)
await DoWorkAsync(CancellationToken.None).ConfigureAwait(false);
// ★NG: Delayもtoken無し(待機が止まらない)
await Task.Delay(200).ConfigureAwait(false);
}
}
直す(ループ内で監視し、待機もtoken対応へ寄せる):
using System.Threading;
using System.Threading.Tasks;
public async Task PollAsync(CancellationToken cancellationToken)
{
while (true)
{
// ★OK: ループ内で監視(渡すだけでは足りない)
cancellationToken.ThrowIfCancellationRequested();
// ★OK: 下へtokenを渡す
await DoWorkAsync(cancellationToken).ConfigureAwait(false);
// ★OK: 待機もtoken対応(止められる)
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
}
}
ポイント:
- ループは
ThrowIfCancellationRequested()が要る - 待機もtoken対応へ寄せる
8-5. 秒数: 下層が勝手に持つと説明できない
狙い: timeout秒数の責任が曖昧になる形を減らし、起動点/設定側で説明できる形へ寄せる。
悪い例(下層が勝手に秒数を持つ):
using System;
using System.Threading;
using System.Threading.Tasks;
public async Task<string> LoadDataAsync(string endpoint, CancellationToken cancellationToken)
{
// ★NG: 下層に秒数が埋まる(呼び出し側が説明できない)
var timeout = TimeSpan.FromSeconds(2);
return await CancelOnTimeout.Run(
(t) => _api.GetStringAsync(endpoint, t),
timeout,
cancellationToken);
}
直す(呼び出し側で秒数を決める):
using System;
using System.Threading;
using System.Threading.Tasks;
public async Task<string> LoadDataAsync(string endpoint, CancellationToken cancellationToken)
{
// ★OK: 仕様(秒数)は起動点/設定側で決める(ここは受け取り側の例)
var timeout = TimeSpan.FromSeconds(10);
return await CancelOnTimeout.Run(
(t) => _api.GetStringAsync(endpoint, t),
timeout,
cancellationToken);
}
ポイント:
- 秒数は
TimeSpanで扱う - 秒数は起動点/設定側で説明できる場所へ寄せる
8-6. ログ: timeoutは調査できる形で残す
狙い: 「何を待っていたか」が追えるログへ寄せ、調査を短くする。
残す軸:
- 操作名(op)
- 対象(相手/識別子)
- timeout値
- 試行回数
- cancelかtimeoutか
例(概念):
- Timeout:
op=GetString target=... timeout=10s attempt=1 - Canceled:
op=GetString target=... after=3.2s
ポイント:
- timeout値は起動点側で決め、その値をログへ残す
- cancel/timeoutを区別し、通知条件が揃う
9. チェックリスト:レビューで見る所(統合版)
結論: ここを通すだけで、無限待ちと停止不能の混入が減る。
| 観点 | OK例 | NG例 | ありがちな見落とし | 指摘の方向性 |
|---|---|---|---|---|
| timeoutの責任 | 起動点/設定側でTimeSpanを決める |
下層に秒数が埋まる | どこで何秒待つか説明できない | 秒数の責任を起動点側へ寄せる |
| token伝播 | 引数で受けて末端まで渡す | 途中でtokenが消える | 停止要求が届かない | tokenが末端まで流れる形へ寄せる |
| token監視 | ループ内で監視する | tokenを渡すだけ | 停止要求が来ても続く | ループ内監視を揃える |
| 待機token対応 | Task.Delay(..., token) |
Task.Delay(...) |
キャンセルしても待機が止まらない | 待機もtoken対応へ寄せる |
| WhenAnyの使い方 | cancel/timeoutを区別できる | 例外が混ざる | timeout扱いが曖昧 | 区別できる分岐へ寄せる |
| timeout時の停止 | 連結token+CancelAfter
|
待ちだけ抜ける | タスクが残りやすい | timeoutで停止要求も流す形へ寄せる |
| 観測 | op/対象/timeout/回数/種別が残る | 何も残らない | 状況が読めない | 調査できるログ項目へ寄せる |
| リトライ整合 | 回数×timeoutが説明できる | 最悪待ち時間が不明 | timeoutが運用の上限を超える | 上限が見える形へ寄せる |
禁書庫A: 状況別の型(必要時に参照)
| 状況 | 起動点で決めるもの | 推奨の型 | 例外/戻り値 | 補足 |
|---|---|---|---|---|
| UI操作 | 応答上限が短い |
CancelAfter+連結token |
TimeoutException/Cancel | UIは止めたい要求が強い |
| バッチ | 上限とリトライ整合 | timeout×回数の上限 | TimeoutException | 最悪待ち時間を見える化 |
| 外部API | 操作単位の上限 |
Task.WhenAny or 連結token |
TimeoutException |
HttpClient.Timeoutだけに寄せない |
| 内部処理 | 中断可能性 | token監視(ThrowIf...) | OperationCanceledException | ループはtoken監視が必須 |
10. セルフチェック(5問)
結論: 5問に答えられると、実装・レビューの判断が速くなる。
-
ctが何の略として使われるか言えるか - タイムアウトとキャンセルの違いを1行で言えるか
-
Task.WhenAnyが止める対象を言えるか(待ち/処理) - timeout秒数を決める「起動点」の例を2つ挙げられるか
- ループが止まらない形を見つけ、直す手が動くか
回答例
-
ctは CancellationToken の省略名として使われることが多い - タイムアウトは待つ上限(仕様)、キャンセルは止める要求(制御)
-
WhenAnyは待ちを抜けるだけ。処理停止はtoken監視が前提 - UIイベント/外部I/O呼び出し直前/設定読み込み直後/ジョブ起動点
- ループ内で
ThrowIfCancellationRequested()、待機はTask.Delay(..., token)へ寄せる
関連トピック
- シリーズ総合Index(読む順・公開済リンクが最新)
- R06 【掟・判例】非同期の掟(UIスレッド/await帰還/デッドロック)
- R04 【掟・判例】例外設計の掟(握り潰し禁止/throw;)
- R05 【掟・判例】ログ設計の掟(証拠を残す)