0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

R10 【掟・判例】返ってこないを終わらせる ― タイムアウトとキャンセル CancellationToken Task.WhenAnyで無限待ちを切る

Posted at

連載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点が揃うと、無限待ちと停止不能の混入が減る。

  1. timeout秒数を起動点で決める(秒数は仕様。下層へ埋めない)
  2. CancellationTokenを末端まで渡す(止められない処理を増やさない)
  3. 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) にすると、キャンセルで待ちも解除できる
  • TimeoutExceptionOperationCanceledException を分けられる

注意:

  • 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イベントに限定
  • OperationCanceledExceptionTimeoutException を分ける
  • 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問に答えられると、実装・レビューの判断が速くなる。

  1. ct が何の略として使われるか言えるか
  2. タイムアウトとキャンセルの違いを1行で言えるか
  3. Task.WhenAny が止める対象を言えるか(待ち/処理)
  4. timeout秒数を決める「起動点」の例を2つ挙げられるか
  5. ループが止まらない形を見つけ、直す手が動くか
回答例
  1. ct は CancellationToken の省略名として使われることが多い
  2. タイムアウトは待つ上限(仕様)、キャンセルは止める要求(制御)
  3. WhenAnyは待ちを抜けるだけ。処理停止はtoken監視が前提
  4. UIイベント/外部I/O呼び出し直前/設定読み込み直後/ジョブ起動点
  5. ループ内でThrowIfCancellationRequested()、待機はTask.Delay(..., token)へ寄せる

関連トピック


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?