【C#】Taskにタイムアウトを追加する

C# の非同期タスクにタイムアウトを追加して処理をキャンセルする方法の紹介です。

.NET の標準ライブラリのネットワーク呼び出し系の API は呼び出しにタイムアウトがついてることが多いですが、一般の API だと通常はタイムアウトがありません。上位から CancellationToken token を受け取るけどタイムアウトも足して呼び出したい、のようなシーンに何度か遭遇したので検証ついでに汎用処理を作成しました。

確認環境

  • .NET 8.0(C# 12)
  • Visual Studio 2022(17.14.9)

実装例

ある async な処理をタイムアウト付きで汎用的に呼び出す方法の紹介です。

確認用の実装

ユーザーによる手動キャンセル + 5秒でタイムアウトを指定する処理の例です。

static void Main(string[] args)
{
    CancellationTokenSource cts = new();

    Task.Run(async () =>
    {
        try
        {
            Console.WriteLine("Start Task");

            await RunWithTimeoutAsync(TimeSpan.FromSeconds(5), cts.Token);
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        finally
        {
            Console.WriteLine("End Task");
        }
    });

    // 5秒以内に何かキーを押せば上位のキャンセル
    // 何もしなければ5秒後にタイムアウトが発生
    Console.WriteLine("キャンセルする場合何かキーを押してください。");
    Console.ReadKey();

    cts.Cancel();

    Console.ReadLine();
}

// タスクにタイムアウトを追加する標準的な実装
public static async Task RunWithTimeoutAsync(
        TimeSpan timeout, 
        CancellationToken token = default)
{
    using CancellationTokenSource ctsTimeout = new(timeout);
    using var ctsSet = 
        CancellationTokenSource.CreateLinkedTokenSource(
            token, 
            ctsTimeout.Token);
    try
    {
        TimeSpan _ = await BarAsync(ctsSet.Token); // 戻り値は必要ないので放棄
    }
    catch (OperationCanceledException ex) 
        when (ctsTimeout.Token.IsCancellationRequested)
    {
        // タイムアウトした事を通知する例外に変換する
        throw new TimeoutException("The request was canceled " +
                $"because {timeout.TotalSeconds} seconds had elapsed.", ex);
    }
    // ユーザーのキャンセルはOperationCanceledExceptionがそのまま報告される
}

// 60分帰ってこないタスク
public static async Task<TimeSpan> BarAsync(CancellationToken token = default)
{
    TimeSpan wait = TimeSpan.FromMinutes(60);
    await Task.Delay(wait, token);
    return wait; // 何分待ったか返す
}

タイムアウトが発生した場合 TimeoutException が発生するようにどのトークンがキャンセルされたのかを判定して例外を作成しています。

汎用実装

上記の実装例を汎用的なユーティリティとして実装したものが以下になります。

public static class TaskUtil
{
    // タスクにタイムアウトを追加する標準的な実装
    public static async Task RunWithTimeoutAsync(
        Func<CancellationToken, Task> task, 
        TimeSpan timeout, 
        CancellationToken token = default)
    {
        using CancellationTokenSource ctsTimeout = new(timeout);
        using var ctsSet = 
            CancellationTokenSource.CreateLinkedTokenSource(
                token,
                ctsTimeout.Token);
        try
        {
            await task(ctsSet.Token);
        }
        catch (OperationCanceledException ex)
            when (ctsTimeout.Token.IsCancellationRequested)
        {
            throw new TimeoutException("The request was canceled " +
                $"because {timeout.TotalSeconds} seconds had elapsed.", ex);
        }
    }

    // 戻り値がある場合の処理
    public static async Task<T> RunWithTimeoutAsync<T>(
        Func<CancellationToken, Task<T>> task, 
        TimeSpan timeout, 
        CancellationToken token = default)
    {
        using CancellationTokenSource ctsTimeout = new(timeout);
        using var ctsSet = 
            CancellationTokenSource.CreateLinkedTokenSource(
                token,
                ctsTimeout.Token);
        try
        {
            return await task(ctsSet.Token);
        }
        catch (OperationCanceledException ex)
            when (ctsTimeout.Token.IsCancellationRequested)
        {
            throw new TimeoutException("The request was canceled " + 
                $"because {timeout.TotalSeconds} seconds had elapsed.", ex);
        }
    }
}