异步等待 Task<T> 完成并设置超时时间

528

我希望等待一个特定规则的Task<T> 执行完成: 如果它在X毫秒后还没有完成,我想向用户显示一条消息。 如果它在Y毫秒后仍未完成,我想自动请求取消

我可以使用Task.ContinueWith异步等待任务完成(即在完成任务时安排要执行的操作),但是无法指定超时时间。 我可以使用Task.Wait同步等待任务以设置超时时间,但会阻塞线程。 如何异步等待任务完成并设置超时时间呢?


3
没错,我很惊讶它没有提供超时功能。也许在.NET 5.0中会提供吧……当然我们可以将超时功能集成到任务本身,但那样不太好,这类功能应该是免费的。 - Aliostad
5
虽然你描述的双层超时仍需要逻辑,但.NET 4.5确实提供了一种简单的方法来创建基于超时的CancellationTokenSource。构造函数有两个重载,一个接受以毫秒为单位的整数延迟,另一个接受TimeSpan延迟。 - patridge
完整的简单库源代码在这里:http://stackoverflow.com/questions/11831844/unobservedtaskexception-being-throw-but-it-is-handled-by-a-taskscheduler-unobser - user1997529
有没有完整的源代码可用的最终解决方案?也许可以提供更复杂的示例,以便在每个线程中通知错误,并在 WaitAll 后显示摘要? - Kiquenet
1
除了@patridge建议的方法外,还可以使用CancellationTokenSource.CancelAfter(<时间段或毫秒数>)来实现。 - maicalal
我认为 Vijay Nirmal 提供的答案(链接:https://dev59.com/um855IYBdhLWcg3woVw_#68998339),展示了最新的 .NET 6 Task.WaitAsync API,应该被采纳。 - Theodor Zoulias
20个回答

738
这样怎么样:
int timeout = 1000;
var task = SomeOperationAsync();
if (await Task.WhenAny(task, Task.Delay(timeout)) == task) {
    // task completed within timeout
} else { 
    // timeout logic
}

这里有一个 来自MS Parallel Library团队的绝佳博客文章“Crafting a Task.TimeoutAfter Method”,提供了更多关于这类问题的信息

附加:根据我的回答评论请求,这里提供了一个包括取消处理的扩展解决方案。注意,将取消操作传递给任务和定时器意味着您的代码中可能存在多种取消方式,您应该确保测试并自信地处理所有这些情况。不要让各种组合只靠计算机运行时的正确判断而留给机会。

int timeout = 1000;
var task = SomeOperationAsync(cancellationToken);
if (await Task.WhenAny(task, Task.Delay(timeout, cancellationToken)) == task)
{
    // Task completed within timeout.
    // Consider that the task may have faulted or been canceled.
    // We re-await the task so that any exceptions/cancellation is rethrown.
    await task;

}
else
{
    // timeout/cancellation logic
}

109
需要说明的是,即使Task.Delay在长时间运行任务之前完成,允许您处理超时情况,但这并不会取消长时间运行的任务本身;WhenAny只是让您知道其中一个传递给它的任务已完成。您需要实现CancellationToken并自己取消长时间运行的任务。 - Jeff Schumacher
43
还需要注意的是,Task.Delay 任务由系统计时器支持,无论 SomeOperationAsync 花费多长时间,直到超时到期,计时器都将继续跟踪。因此,如果这个代码片段在紧密循环中频繁执行,你会消耗系统资源,直到所有计时器超时。解决这个问题的方法是使用 CancellationToken,将其传递给 Task.Delay(timeout, cancellationToken),并在 SomeOperationAsync 完成时取消它以释放计时器资源。 - Andrew Arnott
17
取消代码正在做太多的工作。尝试使用以下代码替换:int timeout = 1000; var cancellationTokenSource = new CancellationTokenSource(timeout); var cancellationToken = tokenSource.Token; var task = SomeOperationAsync(cancellationToken);try { await task; // 在任务成功完成时添加相应的代码 } catch (OperationCancelledException) { // 在超时情况下添加相应的代码 } - srm
3
当等待“任务”时,任何由该任务存储的异常都会在此时重新抛出。这使得你有机会捕获“操作取消异常”(如果已取消)或任何其他异常(如果故障)。 - Andrew Arnott
4
问题是如何异步等待任务完成。使用Task.Wait(timeout)会同步阻塞,而不是异步等待。 - Andrew Arnott
显示剩余11条评论

325

以下是一个扩展方法版本,它包含了在原始任务完成时取消超时的功能,正如Andrew Arnott在他的答案的评论中建议的那样。

public static async Task<TResult> TimeoutAfter<TResult>(this Task<TResult> task, TimeSpan timeout) {

    using (var timeoutCancellationTokenSource = new CancellationTokenSource()) {

        var completedTask = await Task.WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token));
        if (completedTask == task) {
            timeoutCancellationTokenSource.Cancel();
            return await task;  // Very important in order to propagate exceptions
        } else {
            throw new TimeoutException("The operation has timed out.");
        }
    }
}

16
请给这个人一些投票。优雅的解决方案。如果你的调用没有返回类型,请确保只删除TResult。 - Lucas
7
CancellationTokenSource 应该被视为可回收对象,并且应该被置于 using 代码块中以确保正确释放。翻译完成,没有返回额外的内容。 - PeterM
7
当等待一个任务两次时,第二次等待将只返回结果,并不会执行两次。你可以认为执行两次等价于 task.Result - M. Mimpen
9
超时后,原始任务(task)是否仍会继续运行? - jag
10
小的改进机会:TimeoutException已经有一个合适的默认消息。将其覆盖为“操作已超时”没有增加任何价值,实际上会造成一些困惑,因为这暗示着需要覆盖它的原因。 - Edward Brey
显示剩余9条评论

89

从 .Net 6(预览版7)或更新版本开始,有一个新的内置方法Task.WaitAsync可用于实现此目的。

// Using TimeSpan
await myTask.WaitAsync(TimeSpan.FromSeconds(10));

// Using CancellationToken
await myTask.WaitAsync(cancellationToken);

// Using both TimeSpan and CancellationToken
await myTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken);

如果任务在TimeSpanCancellationToken之前没有完成,则会分别抛出TimeoutExceptionTaskCanceledException
try
{
    await myTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken);

}
catch (TaskCanceledException)
{
    Console.WriteLine("Task didn't get finished before the `CancellationToken`");
}
catch (TimeoutException)
{
    Console.WriteLine("Task didn't get finished before the `TimeSpan`");
}

调用任务后,我如何知道任务是否成功完成或超时? - Leo Bottaro
1
@LeoBottaro 我已经更新了我的答案并添加了相关信息。请查看。 - Vijay Nirmal
太好了!我觉得这种方式更简单、更好用。 - undefined

51

您可以使用Task.WaitAny来等待多个任务中的第一个完成。

您可以创建另外两个任务(在特定的超时时间后完成),然后使用WaitAny等待其中任何一个完成。如果首先完成的任务是您需要执行的任务,那么您就完成了它。如果首先完成的任务是超时任务,那么您可以对超时做出反应(例如请求取消)。


1
我曾经看到一个我非常尊重的MVP使用过这种技术,它对我来说比被接受的答案更加清晰。也许举个例子会有更多的投票!我很愿意去做,但是我没有足够的任务经验来确信它会有帮助 :) - GrahamMc
3
如果你不介意有一个线程会被阻塞,那就没有问题。我采取的解决方案是下面这个,因为没有线程被阻塞。我读了那篇博客文章,觉得非常好。 - JJschk
@JJschk,你提到了你采用了下面的解决方案...那个是指哪一个?是根据SO的顺序吗? - BozoJoe
如果我不想取消慢速任务怎么办?我想在它完成时处理它,但从当前方法中返回。 - Akmal Salikhov

29

这是之前答案的稍微改进版本。

  • 除了Lawrence的回答,当超时发生时它会取消原始任务。
  • 除了sjb的回答变体2和3,你可以为原始任务提供CancellationToken,当超时发生时,你会得到TimeoutException而不是OperationCanceledException
async Task<TResult> CancelAfterAsync<TResult>(
    Func<CancellationToken, Task<TResult>> startTask,
    TimeSpan timeout, CancellationToken cancellationToken)
{
    using (var timeoutCancellation = new CancellationTokenSource())
    using (var combinedCancellation = CancellationTokenSource
        .CreateLinkedTokenSource(cancellationToken, timeoutCancellation.Token))
    {
        var originalTask = startTask(combinedCancellation.Token);
        var delayTask = Task.Delay(timeout, timeoutCancellation.Token);
        var completedTask = await Task.WhenAny(originalTask, delayTask);
        // Cancel timeout to stop either task:
        // - Either the original task completed, so we need to cancel the delay task.
        // - Or the timeout expired, so we need to cancel the original task.
        // Canceling will not affect a task, that is already completed.
        timeoutCancellation.Cancel();
        if (completedTask == originalTask)
        {
            // original task completed
            return await originalTask;
        }
        else
        {
            // timeout
            throw new TimeoutException();
        }
    }
}

用法

InnerCallAsync 可能需要很长时间才能完成。 CallAsync 使用超时来包装它。


async Task<int> CallAsync(CancellationToken cancellationToken)
{
    var timeout = TimeSpan.FromMinutes(1);
    int result = await CancelAfterAsync(ct => InnerCallAsync(ct), timeout,
        cancellationToken);
    return result;
}

async Task<int> InnerCallAsync(CancellationToken cancellationToken)
{
    return 42;
}

1
感谢您提供的解决方案!看起来您应该将“timeoutCancellation”传递给“delayTask”。目前,如果您触发取消操作,“CancelAfterAsync”可能会抛出“TimeoutException”而不是“TaskCanceledException”,因为“delayTask”可能会先完成。 - AxelUser
@AxelUser,你说得对。我花了一个小时进行了一堆单元测试才明白发生了什么 :) 我假设当WhenAny给出的两个任务都被同一个标记取消时,WhenAny将返回第一个任务。这个假设是错误的。我已经编辑了答案。谢谢! - Josef Bláha
我很难弄清楚如何使用定义的Task<SomeResult>函数来调用它,你能否提供一个调用示例? - jhaagsma
1
@jhaagsma,示例已添加! - Josef Bláha
@JosefBláha 非常感谢!我还在慢慢理解lambda风格的语法,这对我来说是意料之外的——通过传递lambda函数将令牌传递到CancelAfterAsync的主体中。真巧妙! - jhaagsma
这非常有用!你是否有将其作为NuGet包提供的计划?将其作为一个带有单元测试的NuGet包引入会更加方便,而不是复制粘贴。 - Daniel Lo Nigro

26

使用Stephen Cleary优秀的AsyncEx库,你可以这样做:

TimeSpan timeout = TimeSpan.FromSeconds(10);

using (var cts = new CancellationTokenSource(timeout))
{
    await myTask.WaitAsync(cts.Token);
}

如果超时,将抛出TaskCanceledException异常。


4
这现在已经内置于 .Net 6 中了!https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.waitasync?view=net-6.0 - Rotem

18

这里是一个完整的示例,基于得票最高的答案:

int timeout = 1000;
var task = SomeOperationAsync();
if (await Task.WhenAny(task, Task.Delay(timeout)) == task) {
    // task completed within timeout
} else { 
    // timeout logic
}

该答案实现的主要优点是引入了泛型,因此函数(或任务)可以返回值。这意味着任何现有函数都可以被包装在一个超时函数中,例如:

之前:

int x = MyFunc();

之后:

// Throws a TimeoutException if MyFunc takes more than 1 second
int x = TimeoutAfter(MyFunc, TimeSpan.FromSeconds(1));

这段代码需要 .NET 4.5 版本。

using System;
using System.Threading;
using System.Threading.Tasks;

namespace TaskTimeout
{
    public static class Program
    {
        /// <summary>
        ///     Demo of how to wrap any function in a timeout.
        /// </summary>
        private static void Main(string[] args)
        {

            // Version without timeout.
            int a = MyFunc();
            Console.Write("Result: {0}\n", a);
            // Version with timeout.
            int b = TimeoutAfter(() => { return MyFunc(); },TimeSpan.FromSeconds(1));
            Console.Write("Result: {0}\n", b);
            // Version with timeout (short version that uses method groups). 
            int c = TimeoutAfter(MyFunc, TimeSpan.FromSeconds(1));
            Console.Write("Result: {0}\n", c);

            // Version that lets you see what happens when a timeout occurs.
            try
            {               
                int d = TimeoutAfter(
                    () =>
                    {
                        Thread.Sleep(TimeSpan.FromSeconds(123));
                        return 42;
                    },
                    TimeSpan.FromSeconds(1));
                Console.Write("Result: {0}\n", d);
            }
            catch (TimeoutException e)
            {
                Console.Write("Exception: {0}\n", e.Message);
            }

            // Version that works on tasks.
            var task = Task.Run(() =>
            {
                Thread.Sleep(TimeSpan.FromSeconds(1));
                return 42;
            });

            // To use async/await, add "await" and remove "GetAwaiter().GetResult()".
            var result = task.TimeoutAfterAsync(TimeSpan.FromSeconds(2)).
                           GetAwaiter().GetResult();

            Console.Write("Result: {0}\n", result);

            Console.Write("[any key to exit]");
            Console.ReadKey();
        }

        public static int MyFunc()
        {
            return 42;
        }

        public static TResult TimeoutAfter<TResult>(
            this Func<TResult> func, TimeSpan timeout)
        {
            var task = Task.Run(func);
            return TimeoutAfterAsync(task, timeout).GetAwaiter().GetResult();
        }

        private static async Task<TResult> TimeoutAfterAsync<TResult>(
            this Task<TResult> task, TimeSpan timeout)
        {
            var result = await Task.WhenAny(task, Task.Delay(timeout));
            if (result == task)
            {
                // Task completed within timeout.
                return task.GetAwaiter().GetResult();
            }
            else
            {
                // Task timed out.
                throw new TimeoutException();
            }
        }
    }
}

注意事项

虽然我已经给出了答案,但通常情况下,在正常操作期间在代码中抛出异常不是一个好的做法,除非你确实必须这样做:

  • 每次抛出异常都是一个极其耗费资源的操作,
  • 如果异常发生在紧密循环内部,它们可能会使你的代码减速100倍或更多。

只有在调用的函数无法修改以便在特定TimeSpan后超时时才使用此代码。

此答案仅适用于处理第三方库,而你无法重构该库以包含超时参数。

如何编写稳健的代码

如果你想编写稳健的代码,一般规则如下:

每个潜在可能无限期阻塞的操作都必须有一个超时时间。

如果你不遵守这个规则,你的代码最终会碰到某些原因失败的操作,然后它将无限期地阻塞,你的应用程序就会永久挂起。

如果在一段时间后设置了合理的超时时间,那么你的应用程序将挂起一段极长的时间(例如30秒),然后它要么显示错误并继续执行,要么重试。


17
像这样的东西怎么样?
    const int x = 3000;
    const int y = 1000;

    static void Main(string[] args)
    {
        // Your scheduler
        TaskScheduler scheduler = TaskScheduler.Default;

        Task nonblockingTask = new Task(() =>
            {
                CancellationTokenSource source = new CancellationTokenSource();

                Task t1 = new Task(() =>
                    {
                        while (true)
                        {
                            // Do something
                            if (source.IsCancellationRequested)
                                break;
                        }
                    }, source.Token);

                t1.Start(scheduler);

                // Wait for task 1
                bool firstTimeout = t1.Wait(x);

                if (!firstTimeout)
                {
                    // If it hasn't finished at first timeout display message
                    Console.WriteLine("Message to user: the operation hasn't completed yet.");

                    bool secondTimeout = t1.Wait(y);

                    if (!secondTimeout)
                    {
                        source.Cancel();
                        Console.WriteLine("Operation stopped!");
                    }
                }
            });

        nonblockingTask.Start();
        Console.WriteLine("Do whatever you want...");
        Console.ReadLine();
    }

您可以使用另一个任务来使用Task.Wait选项而不会阻塞主线程。

实际上,在这个例子中,你并不是在t1内等待,而是在一个更高级的任务中等待。我会尝试提供一个更详细的例子。 - as-cii

9

使用计时器来处理消息和自动取消。当任务完成时,在计时器上调用Dispose,以便它们永远不会触发。这里是一个例子;将taskDelay更改为500、1500或2500,以查看不同的情况:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication1
{
    class Program
    {
        private static Task CreateTaskWithTimeout(
            int xDelay, int yDelay, int taskDelay)
        {
            var cts = new CancellationTokenSource();
            var token = cts.Token;
            var task = Task.Factory.StartNew(() =>
            {
                // Do some work, but fail if cancellation was requested
                token.WaitHandle.WaitOne(taskDelay);
                token.ThrowIfCancellationRequested();
                Console.WriteLine("Task complete");
            });
            var messageTimer = new Timer(state =>
            {
                // Display message at first timeout
                Console.WriteLine("X milliseconds elapsed");
            }, null, xDelay, -1);
            var cancelTimer = new Timer(state =>
            {
                // Display message and cancel task at second timeout
                Console.WriteLine("Y milliseconds elapsed");
                cts.Cancel();
            }
                , null, yDelay, -1);
            task.ContinueWith(t =>
            {
                // Dispose the timers when the task completes
                // This will prevent the message from being displayed
                // if the task completes before the timeout
                messageTimer.Dispose();
                cancelTimer.Dispose();
            });
            return task;
        }

        static void Main(string[] args)
        {
            var task = CreateTaskWithTimeout(1000, 2000, 2500);
            // The task has been started and will display a message after
            // one timeout and then cancel itself after the second
            // You can add continuations to the task
            // or wait for the result as needed
            try
            {
                task.Wait();
                Console.WriteLine("Done waiting for task");
            }
            catch (AggregateException ex)
            {
                Console.WriteLine("Error waiting for task:");
                foreach (var e in ex.InnerExceptions)
                {
                    Console.WriteLine(e);
                }
            }
        }
    }
}

此外,Async CTP也提供了TaskEx.Delay方法,可以为您包装计时器的任务。这可以让您更好地控制,例如在定时器触发时设置继续执行的TaskScheduler。
private static Task CreateTaskWithTimeout(
    int xDelay, int yDelay, int taskDelay)
{
    var cts = new CancellationTokenSource();
    var token = cts.Token;
    var task = Task.Factory.StartNew(() =>
    {
        // Do some work, but fail if cancellation was requested
        token.WaitHandle.WaitOne(taskDelay);
        token.ThrowIfCancellationRequested();
        Console.WriteLine("Task complete");
    });

    var timerCts = new CancellationTokenSource();

    var messageTask = TaskEx.Delay(xDelay, timerCts.Token);
    messageTask.ContinueWith(t =>
    {
        // Display message at first timeout
        Console.WriteLine("X milliseconds elapsed");
    }, TaskContinuationOptions.OnlyOnRanToCompletion);

    var cancelTask = TaskEx.Delay(yDelay, timerCts.Token);
    cancelTask.ContinueWith(t =>
    {
        // Display message and cancel task at second timeout
        Console.WriteLine("Y milliseconds elapsed");
        cts.Cancel();
    }, TaskContinuationOptions.OnlyOnRanToCompletion);

    task.ContinueWith(t =>
    {
        timerCts.Cancel();
    });

    return task;
}

他不希望当前线程被阻塞,也就是说,不使用 task.Wait() - Cheng Chen
@Danny:那只是为了让示例完整。在ContinueWith之后,您可以返回并让任务运行。我会更新我的答案以使其更清晰。 - Quartermeister
2
@dtb:如果你把t1设为Task<Task<Result>>,然后调用TaskExtensions.Unwrap会怎样?你可以从内部lambda返回t2,并且你可以在展开的任务后面添加继续操作。 - Quartermeister
太棒了!那完美地解决了我的问题。谢谢!我想我会采用@AS-CII提出的解决方案,尽管我希望我也能接受你的答案,因为你建议使用TaskExtensions.Unwrap。我应该开一个新问题,这样你就可以得到你应得的声望吗? - dtb

7

随着 .Net 6(预览版7)的推出,现在可以使用新的WaitAsync(TimeSpan, CancellationToken)来满足这个特定需求。

如果您能使用 .Net6,这个版本还被描述为优化了大部分在此帖中提出的好解决方案。

(感谢所有参与者,因为我用了你们的解决方案多年)


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接