C# Tips | ログ・例外・診断:タイムアウト制御

C# C#
スポンサーリンク

はじめに:タイムアウト制御は「いつまでも待たない」ための安全装置

業務システムで一番イヤなのは、「固まっているのか、まだ処理中なのか分からない状態」です。
外部 API、DB、ファイルアクセス、重い計算処理など、いつ終わるか分からない処理を「無制限に待つ」と、次のような問題が起きます。

画面が固まってユーザーが不安になる
バッチがいつまでも終わらず、後続ジョブが詰まる
スレッドや接続が占有され続け、システム全体が重くなる

これを防ぐための仕組みが タイムアウト制御 です。
「ここまで待っても終わらなかったら、諦めて中断する」という“時間の上限”を決めるイメージです。

ここでは、初心者向けに

タイムアウト制御の考え方
CancellationTokenSource を使った汎用的なタイムアウト
Task.WhenAny を使った「時間 vs 処理」の競争
HttpClient など外部アクセスでのタイムアウト
小さなユーティリティとしての「タイムアウト付き実行」

を、例題付きでかみ砕いて説明します。


タイムアウト制御の基本イメージ

「処理」と「時間」を競争させる

タイムアウト制御の考え方は、とてもシンプルです。

処理が終わるのを待つ
同時に、「一定時間が経過する」のも待つ
どちらが先に終わるかで、結果を決める

つまり、「処理」と「時間」を競争させるイメージです。

C# では、これを TaskCancellationTokenSource を使って表現することが多いです。
「時間が先に来たらキャンセルを飛ばす」「処理が先に終わったらキャンセルは無視する」という形です。

ここでの重要ポイントは、「タイムアウトは“例外的な失敗”ではなく、“設計された諦め方”」だということです。
「ここまで待っても終わらないなら、ユーザーにもシステムにも良くないから、きっぱり諦める」というルールをコードにするのがタイムアウト制御です。


CancellationTokenSource を使った基本的なタイムアウト

CancelAfter で「この時間が来たらキャンセル」を予約する

C# でタイムアウトを扱うときの基本ツールが CancellationTokenSource です。
これを使うと、「このトークンに対して、何秒後にキャンセルを飛ばすか」を簡単に指定できます。

まずは、非同期メソッドに CancellationToken を渡しておく前提から。

public async Task DoSomethingAsync(CancellationToken cancellationToken)
{
    // キャンセル可能な処理の例
    await Task.Delay(5000, cancellationToken); // 5 秒待つが、キャンセルされたら例外が飛ぶ

    // 何か重い処理…
}
C#

このメソッドに「3 秒のタイムアウト」をかけて呼び出してみます。

public async Task RunWithTimeoutAsync()
{
    using var cts = new CancellationTokenSource();

    // 3 秒後に自動的にキャンセルを飛ばす
    cts.CancelAfter(TimeSpan.FromSeconds(3));

    try
    {
        await DoSomethingAsync(cts.Token);
        Console.WriteLine("処理がタイムアウト前に完了しました。");
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("タイムアウトにより処理を中断しました。");
    }
}
C#

ここでの重要ポイントは二つです。

一つ目は、「タイムアウトは CancellationTokenSource.CancelAfter で表現する」ということ。
二つ目は、「キャンセルされた側は OperationCanceledException を投げるので、それをキャッチして“タイムアウトとして扱う”」ということです。

この形を覚えておくと、ほとんどの非同期処理にタイムアウトをかけられるようになります。


Task.WhenAny を使った「処理 vs タイムアウト」の競争

「処理タスク」と「タイムアウトタスク」を同時に待つ

もう一つよく使われるパターンが、Task.WhenAny を使う方法です。
これは、「複数のタスクのうち、どれか一つが終わるのを待つ」メソッドです。

これを使って、「処理タスク」と「タイムアウト用の遅延タスク」を競争させます。

public static async Task RunWithTimeoutAsync(Func<Task> action, TimeSpan timeout)
{
    var timeoutTask = Task.Delay(timeout);
    var actionTask = action();

    var completed = await Task.WhenAny(actionTask, timeoutTask);

    if (completed == timeoutTask)
    {
        // タイムアウト側が先に終わった
        throw new TimeoutException($"処理がタイムアウトしました。(Timeout={timeout})");
    }

    // 処理タスクが先に終わった場合は、その結果を待つ(例外があればここで飛ぶ)
    await actionTask;
}
C#

使い方の例です。

await RunWithTimeoutAsync(
    async () => await DoSomethingAsync(CancellationToken.None),
    TimeSpan.FromSeconds(3));
C#

ここでの重要ポイントは、「Task.WhenAny で“どちらが先に終わったか”を判定する」ということです。
この方法は、「処理側がキャンセル対応していない場合」にも使えますが、その場合は“本当に処理が止まるわけではない”点に注意が必要です(後述します)。


「タイムアウト=処理が止まる」とは限らないことに注意

キャンセル対応していない処理は、裏で走り続ける

タイムアウト制御でよく誤解されるのが、「タイムアウトしたら処理が止まる」というイメージです。
実際には、処理側がキャンセルに対応していない限り、裏では処理が走り続ける ことがあります。

例えば、先ほどの Task.WhenAny 方式で、キャンセル非対応の重い計算をタイムアウト付きで呼んだ場合を考えます。

public async Task HeavyWorkAsync()
{
    // キャンセルトークンを受け取っていない
    await Task.Run(() =>
    {
        // 10 秒かかる重い処理
        Thread.Sleep(10000);
    });
}
C#

これを RunWithTimeoutAsync で 3 秒タイムアウトにして呼ぶと、
3 秒後に TimeoutException は投げられますが、HeavyWorkAsync の中の処理は 10 秒間走り続けます。

ここでの超重要ポイントは、

「タイムアウトは“呼び出し側が待つのをやめる”だけであって、“処理そのものを強制停止する”わけではない」

ということです。
処理を本当に中断したいなら、処理側が CancellationToken を受け取り、適切な場所でキャンセルをチェックする必要があります。


HttpClient でのタイムアウト制御

Timeout プロパティと CancellationToken の両方を理解する

外部 API 呼び出しでよく使う HttpClient には、Timeout プロパティがあります。

var httpClient = new HttpClient
{
    Timeout = TimeSpan.FromSeconds(5)
};

var response = await httpClient.GetAsync("https://example.com");
C#

この場合、「リクエスト全体が 5 秒以内に終わらなければ TaskCanceledException が投げられる」という挙動になります。

さらに細かく制御したい場合は、CancellationTokenSource と組み合わせます。

public async Task<string> GetWithTimeoutAsync(string url, TimeSpan timeout)
{
    using var cts = new CancellationTokenSource(timeout);

    using var httpClient = new HttpClient();

    try
    {
        var response = await httpClient.GetAsync(url, cts.Token);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }
    catch (OperationCanceledException)
    {
        throw new TimeoutException($"HTTP リクエストがタイムアウトしました。(Timeout={timeout})");
    }
}
C#

ここでの重要ポイントは、「HttpClient.Timeout だけに頼るのではなく、CancellationToken で自分のルールのタイムアウトも書ける」ということです。
複数の外部呼び出しをまとめて「全体で 10 秒以内に終わらせたい」といった場合は、共通の CancellationTokenSource を使う設計も有効です。


タイムアウト付き実行ユーティリティを作る

「処理を渡すと、指定時間内に終わらなければ TimeoutException を投げる」形

毎回同じようなタイムアウトコードを書くのは面倒なので、
小さなユーティリティとしてまとめておくと便利です。

CancellationToken 対応版の例です。

public static class TimeoutRunner
{
    public static async Task RunAsync(
        Func<CancellationToken, Task> action,
        TimeSpan timeout)
    {
        using var cts = new CancellationTokenSource(timeout);

        try
        {
            await action(cts.Token);
        }
        catch (OperationCanceledException) when (cts.IsCancellationRequested)
        {
            throw new TimeoutException($"処理がタイムアウトしました。(Timeout={timeout})");
        }
    }

    public static async Task<T> RunAsync<T>(
        Func<CancellationToken, Task<T>> action,
        TimeSpan timeout)
    {
        using var cts = new CancellationTokenSource(timeout);

        try
        {
            return await action(cts.Token);
        }
        catch (OperationCanceledException) when (cts.IsCancellationRequested)
        {
            throw new TimeoutException($"処理がタイムアウトしました。(Timeout={timeout})");
        }
    }
}
C#

使い方の例です。

var result = await TimeoutRunner.RunAsync(
    async token => await DoSomethingAsync(token),
    TimeSpan.FromSeconds(3));
C#

ここでの重要ポイントは、「タイムアウトのパターンをユーティリティに閉じ込める」ことです。
これにより、業務コード側は「この処理は 3 秒以内に終わらせたい」とだけ書けばよくなり、
タイムアウトの実装詳細(CancellationTokenSource や例外変換)はユーティリティ側に隠せます。


ログと組み合わせたタイムアウト制御

「どの処理が、どれくらいタイムアウトしているか」を見える化する

タイムアウトは、「起きたら終わり」ではなく、「起きた回数や場所を分析する」ことが大事です。
ILogger と組み合わせて、タイムアウト発生時に必ずログを残すようにします。

public static class TimeoutRunnerWithLogging
{
    public static async Task<T> RunAsync<T>(
        Func<CancellationToken, Task<T>> action,
        TimeSpan timeout,
        ILogger logger,
        string context)
    {
        using var cts = new CancellationTokenSource(timeout);

        try
        {
            return await action(cts.Token);
        }
        catch (OperationCanceledException) when (cts.IsCancellationRequested)
        {
            logger.LogWarning(
                "タイムアウト発生。Context={Context} Timeout={Timeout}",
                context,
                timeout);

            throw new TimeoutException($"処理がタイムアウトしました。(Context={context}, Timeout={timeout})");
        }
    }
}
C#

使い方の例です。

var user = await TimeoutRunnerWithLogging.RunAsync(
    async token => await LoadUserFromApiAsync(id, token),
    TimeSpan.FromSeconds(3),
    _logger,
    context: $"ユーザー取得 Id={id}");
C#

ここでの重要ポイントは、「タイムアウトを“静かに握りつぶさない”」ことです。
タイムアウトはシステムの健康状態を示す重要なシグナルなので、
どの処理でどれくらい発生しているかをログから追えるようにしておきましょう。


まとめ:タイムアウト制御は“いつまでも待たない”ための設計

タイムアウト制御の本質を一言で言うと、

「処理が終わるのを無制限に待つのではなく、
決めた時間が来たらきっぱり諦めて、
ユーザーにもシステムにも優しい形で失敗として扱う」

という設計です。

押さえておきたいポイントを整理すると、次のようになります。

タイムアウトは「処理」と「時間」の競争として考える。
CancellationTokenSource.CancelAfterOperationCanceledException を組み合わせるのが基本形。
Task.WhenAny で「処理タスク vs タイムアウトタスク」を競争させる方法もあるが、処理自体は止まらないことに注意。
HttpClient など外部アクセスでは、Timeout プロパティと CancellationToken の両方を理解して使う。
タイムアウト付き実行をユーティリティ化し、ログと組み合わせて「どこでどれくらいタイムアウトしているか」を見える化する。

ここまでイメージできていれば、「とりあえず待ち続ける」コードから卒業して、
“時間にも責任を持った”業務システムらしい設計に一歩近づけます。

タイトルとURLをコピーしました