如果在“等待”之后抛出,则从任务中抛出的异常被吞下

Posted

技术标签:

【中文标题】如果在“等待”之后抛出,则从任务中抛出的异常被吞下【英文标题】:Exception thrown from task is swallowed, if thrown after 'await' 【发布时间】:2019-11-14 04:54:12 【问题描述】:

我正在使用 .NET 的 HostBuilder 编写后台服务。 我有一个名为 MyService 的类,它实现了 BackgroundService ExecuteAsync 方法,我在那里遇到了一些奇怪的行为。 在方法里面我await某个任务,在await之后抛出的任何异常都会被吞掉,但是在await之前抛出的异常会终止进程。

我在各种论坛(堆栈溢出、msdn、medium)上查看了网上信息,但找不到这种行为的解释。

public class MyService : BackgroundService
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        
            await Task.Delay(500, stoppingToken);
            throw new Exception("oy vey"); // this exception will be swallowed
        
    

public class MyService : BackgroundService
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        
            throw new Exception("oy vey"); // this exception will terminate the process
            await Task.Delay(500, stoppingToken);
        
    

我希望这两个异常都会终止进程。

【问题讨论】:

这很有趣,但我很好奇为什么会这样。 请see casperOne 的这个答案,解释了一些按任务处理异常的方法... 你确定是吗?谁打电话给ExecuteAsync @JessedeWit 这与 GC 无关。这是一个 BackgroundService,这意味着只要应用程序存在,它就可能存在 @JessedeWit 也不是这样。这实际上是关于如何调用这些方法。是的,归根结底是关于 GC 的,但这只是因为托管基础设施按照它们的方式工作。 【参考方案1】:

您不必使用BackgroundService。顾名思义,它对于不是流程的主要职责且错误不应导致流程退出的工作很有用。

如果这不符合您的需求,您可以自己创建IHostedService。我使用了下面的WorkerService,它比IApplicationLifetime.StopApplication() 有一些优势。因为async void 在线程池上运行延续,所以可以使用AppDomain.CurrentDomain.UnhandledException 处理错误,并将以错误退出代码终止。有关详细信息,请参阅 XML cmets。

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;

namespace MyWorkerApp.Hosting

    /// <summary>
    /// Base class for implementing a continuous <see cref="IHostedService"/>.
    /// </summary>
    /// <remarks>
    /// Differences from <see cref="BackgroundService"/>:
    /// <list type = "bullet">
    /// <item><description><see cref="ExecuteAsync"/> is repeated indefinitely if it completes.</description></item>
    /// <item><description>Unhandled exceptions are observed on the thread pool.</description></item>
    /// <item><description>Stopping timeouts are propagated to the caller.</description></item>
    /// </list>
    /// </remarks>
    public abstract class WorkerService : IHostedService, IDisposable
    
        private readonly TaskCompletionSource<byte> running = new TaskCompletionSource<byte>();
        private readonly CancellationTokenSource stopping = new CancellationTokenSource();

        /// <inheritdoc/>
        public virtual Task StartAsync(CancellationToken cancellationToken)
        
            Loop();
            async void Loop()
            
                if (this.stopping.IsCancellationRequested)
                
                    return;
                

                try
                
                    await this.ExecuteAsync(this.stopping.Token);
                
                catch (OperationCanceledException) when (this.stopping.IsCancellationRequested)
                
                    this.running.SetResult(default);
                    return;
                

                Loop();
            

            return Task.CompletedTask;
        

        /// <inheritdoc/>
        public virtual Task StopAsync(CancellationToken cancellationToken)
        
            this.stopping.Cancel();
            return Task.WhenAny(this.running.Task, Task.Delay(Timeout.Infinite, cancellationToken)).Unwrap();
        

        /// <inheritdoc/>
        public virtual void Dispose() => this.stopping.Cancel();

        /// <summary>
        /// Override to perform the work of the service.
        /// </summary>
        /// <remarks>
        /// The implementation will be invoked again each time the task completes, until application is stopped (or exception is thrown).
        /// </remarks>
        /// <param name="cancellationToken">A token for cancellation.</param>
        /// <returns>A task representing the asynchronous operation.</returns>
        protected abstract Task ExecuteAsync(CancellationToken cancellationToken);
    

【讨论】:

【参考方案2】:

TL;DR;

不要让异常出现在ExecuteAsync 之外。处理它们、隐藏它们或明确请求关闭应用程序。

在开始第一个异步操作之前不要等待太久

说明

这与await 本身无关。之后抛出的异常将冒泡给调用者。处理它们的是调用者

ExecuteAsync 是由BackgroundService 调用的方法,这意味着该方法引发的任何异常都将由BackgroundService 处理。 That code is:

    public virtual Task StartAsync(CancellationToken cancellationToken)
    
        // Store the task we're executing
        _executingTask = ExecuteAsync(_stoppingCts.Token);

        // If the task is completed then return it, this will bubble cancellation and failure to the caller
        if (_executingTask.IsCompleted)
        
            return _executingTask;
        

        // Otherwise it's running
        return Task.CompletedTask;
    

没有任何东西等待返回的任务,所以这里不会抛出任何东西。检查IsCompleted 是一项优化,可避免在任务已完成时创建异步基础架构。

在调用StopAsync 之前不会再次检查该任务。那时任何异常都会被抛出。

    public virtual async Task StopAsync(CancellationToken cancellationToken)
    
        // Stop called without start
        if (_executingTask == null)
        
            return;
        

        try
        
            // Signal cancellation to the executing method
            _stoppingCts.Cancel();
        
        finally
        
            // Wait until the task completes or the stop token triggers
            await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken));
        

    

从服务到主机

反过来,每个服务的StartAsync方法被Host实现的StartAsync方法调用。代码揭示了发生了什么:

    public async Task StartAsync(CancellationToken cancellationToken = default)
    
        _logger.Starting();

        await _hostLifetime.WaitForStartAsync(cancellationToken);

        cancellationToken.ThrowIfCancellationRequested();
        _hostedServices = Services.GetService<IEnumerable<IHostedService>>();

        foreach (var hostedService in _hostedServices)
        
            // Fire IHostedService.Start
            await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
        

        // Fire IHostApplicationLifetime.Started
        _applicationLifetime?.NotifyStarted();

        _logger.Started();
    

有趣的部分是:

        foreach (var hostedService in _hostedServices)
        
            // Fire IHostedService.Start
            await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
        

直到第一个真正的异步操作的所有代码都在原始线程上运行。当遇到第一个异步操作时,原始线程被释放。 await 之后的所有内容将在该任务完成后恢复。

从主机到 Main()

在 Main() 中用于启动托管服务的 RunAsync() 方法实际上调用了主机的 StartAsync 但不是 StopAsync:

    public static async Task RunAsync(this IHost host, CancellationToken token = default)
    
        try
        
            await host.StartAsync(token);

            await host.WaitForShutdownAsync(token);
        
        finally
        
#if DISPOSE_ASYNC
            if (host is IAsyncDisposable asyncDisposable)
            
                await asyncDisposable.DisposeAsync();
            
            else
#endif
            
                host.Dispose();
            

        
    

这意味着从 RunAsync 到第一个异步操作之前在链中引发的任何异常都会冒泡到启动托管服务的 Main() 调用:

await host.RunAsync();

await host.RunConsoleAsync();

这意味着BackgroundService 对象列表中直到第一个 真正的await 的所有内容都在原始线程上运行。除非处理,否则任何扔在那里的东西都会导致应用程序崩溃。由于IHost.RunAsync()IHost.StartAsync()Main() 中被调用,这就是try/catch 块应该放置的位置。

这也意味着在第一个真正的异步操作之前放置慢代码可能会延迟整个应用程序。

第一个异步操作之后的所有内容都将继续在线程池线程上运行。这就是为什么在调用IHost.StopAsync 关闭托管服务或任何孤立任务获得 GCd 之前,第一个操作之后抛出的异常不会冒泡

结论

不要让异常逃逸ExecuteAsync。抓住它们并妥善处理它们。选项是:

记录并“忽略”它们。这将使 BackgroundService 无效,直到用户或某些其他事件调用应用程序关闭。退出 ExecuteAsync 不会导致应用程序退出。 重试该操作。这可能是简单服务中最常见的选项。 在排队或定时服务中,丢弃出错的消息或事件并移至下一个。这可能是最有弹性的选择。可以检查错误消息、将其移至“死信”队列、重试等。 明确要求关闭。为此,请将IHostedApplicationLifetTime 接口添加为依赖项,并从catch 块中调用StopAsync。这也会在所有其他后台服务上调用 StopAsync

文档

托管服务和BackgroundService 的行为在Implement background tasks in microservices with IHostedService and the BackgroundService class 和Background tasks with hosted services in ASP.NET Core 中进行了描述。

文档没有解释如果其中一项服务抛出会发生什么。它们演示了具有显式错误处理的特定使用场景。 The queued background service example 丢弃导致故障的消息并移至下一条消息:

    while (!cancellationToken.IsCancellationRequested)
    
        var workItem = await TaskQueue.DequeueAsync(cancellationToken);

        try
        
            await workItem(cancellationToken);
        
        catch (Exception ex)
        
            _logger.LogError(ex, 
               $"Error occurred executing nameof(workItem).");
        
    

【讨论】:

惊人的解释!多谢。如果您有任何推荐的资源来阅读有关此主题的更多信息,我将不胜感激。 @TheDotFestClub 链接到源的不幸和痛苦的经历。文档示例展示了如何创建 BackgroundService,但解释了它的行为方式,或者为什么他们的示例以这种方式编写。在ExecuteAsync 正常完成之后,我浪费了很多时间来思考为什么我的应用程序“手” - 直到我意识到 something 必须调用Stop @StephenCleary 我花了最后一个小时在 Github 中寻找代码。我什至不能再想了。我不能重新开始追逐电话。其中一些事情我发现很难,一些我刚刚通过代码理解的失败。 ExecuteAsync 文档并没有说明异常。 @StephenCleary PS 托管服务文章应至少分成 3 篇不同的文章。它试图一次显示太多的东西。它最终既太肤浅又太混乱 我花了很多时间阅读关于这个问题的 github 问题。这是迄今为止我找到的最好的解释。【参考方案3】:

简答

您不是在等待从ExecuteAsync 方法返回的Task。如果您一直在等待它,您就会从第一个示例中观察到异常。

长答案

所以这是关于“被忽略”的任务以及该异常何时传播。

首先是await之前的异常立即传播的原因。

Task DoSomethingAsync()

    throw new Exception();
    await Task.Delay(1);

await 语句之前的部分在您调用它的上下文中同步执行。堆栈保持不变。这就是您在调用站点上观察到异常的原因。现在,你没有对这个异常做任何事情,所以它终止了你的进程。

在第二个例子中:

Task DoSomethingAsync()

    await Task.Delay(1);
    throw new Exception();

编译器制作了包含延续的样板代码。所以你调用方法DoSomethingAsync。该方法立即返回。您无需等待它,因此您的代码会立即继续。样板对await 语句下方的代码行进行了延续。该延续将被称为“不是您的代码的东西”,并将获得异常,并包装在异步任务中。现在,该任务在展开之前不会做任何事情。

未观察到的任务想让某人知道出了问题,所以在终结器中有一个技巧。如果未观察到任务,终结器将抛出异常。所以在这种情况下,任务可以传播其异常的第一个点是在它完成时,在它被垃圾收集之前。

您的进程不会立即崩溃,但它会在任务被垃圾回收之前“崩溃”。

阅读材料:

Finalizers Tasks and unhandled exceptions Dissecting the async methods in C#

【讨论】:

以上是关于如果在“等待”之后抛出,则从任务中抛出的异常被吞下的主要内容,如果未能解决你的问题,请参考以下文章

在线程中处理在 catch 块中抛出的异常的最佳实践。 (。网)

捕获Java线程池执行任务抛出的异常

在 PHP Try Catch 块中抛出异常

在PHP Try Catch块中抛出异常

捕获在不同线程中抛出的异常

Dart:让流中抛出的异常传播并在调用者中捕获它