如果在“await”之后抛出异常,则任务中抛出的异常会被吞噬。

16

我正在使用.NET的HostBuilder编写后台服务。我有一个名为MyService的类,该类实现了BackgroundServiceExecuteAsync方法,但是我在这里遇到了一些奇怪的行为。

在方法内部,我await某个任务,任何在await之后抛出的异常都会被吞掉,但是在await之前抛出的异常会终止进程。

我在所有类型的论坛(stack overflow、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);
        }
    }

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


他的回答没有解释为什么第一个被忽略了而第二个被抛出。 - TheDotFestClub
1
你确定吗?是谁调用了 ExecuteAsync ? - Panagiotis Kanavos
1
@JessedeWit,这不是关于垃圾回收的问题。这是一个BackgroundService,这意味着它可能会在应用程序存活期间一直存在。 - Panagiotis Kanavos
1
@JessedeWit,这也不是关于那个的问题。真正的问题在于如何调用这些方法。是的,在最后,这与GC有关,但只是因为托管基础设施按照其方式运作。 - Panagiotis Kanavos
https://blog.stephencleary.com/2020/05/backgroundservice-gotcha-silent-failure.html - alv
显示剩余8条评论
3个回答

32

TL;DR;

ExecuteAsync中,不要让异常逃脱出去。要么处理它们、隐藏它们,要么显式地请求应用程序关闭。同时,也不要等太久才开始第一个异步操作。

Explanation

这与await本身无关。在await之后抛出的异常会上升到调用者。是调用者处理或不处理异常。

ExecuteAsync是由BackgroundService调用的方法,这意味着该方法引发的任何异常都将由BackgroundService处理。那段代码如下:

    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方法都由主机实现的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的行为在使用IHostedService和BackgroundService类实现微服务中的后台任务使用托管服务在ASP.NET Core中处理后台任务中进行了描述。

文档没有解释如果其中一个服务抛出异常会发生什么。它们演示具有显式错误处理的特定用例场景。排队后台服务示例丢弃导致故障的消息并转到下一条:

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

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

3
非常棒的解释!非常感谢。如果您有任何关于这个话题的推荐阅读资源,我会非常感激。 - TheDotFestClub
@StephenCleary 它也会返回到 Host.StartAsync,在那里它被等待。这意味着如果任何一个服务失败,Host.StartAsync 也将失败,并且异常将进一步冒泡。最终,该异常将到达从 Main() 调用的 RunAsyncRunConsoleAsync 方法中,使用 await host.RunAsync()RunConsoleAsync。直到现在我还没有找到最后一个 RunAsync 的确切代码。 - Panagiotis Kanavos
3
@StephenCleary,我刚刚花了最后一个小时追寻GitHub上的代码。我已经无法再思考了。我不能再次开始追踪所有调用。其中一些我是通过吃亏才找到的,有些失败我刚刚通过查看代码理解。然而,ExecuteAsync文档没有提到任何关于异常的信息。 - Panagiotis Kanavos
2
@StephenCleary PS:托管服务文章应该分成至少3篇不同的文章。它试图一次展示太多东西。结果既太肤浅又太令人困惑。 - Panagiotis Kanavos
1
我花了相当多的时间阅读关于这个问题的Github问题。这是我迄今为止找到的最好的解释。 - Gustav Wengel
显示剩余3条评论

1
你不必使用BackgroundService。正如其名称所示,它对于非进程的主要职责和错误不应导致其退出的工作非常有用。
如果这不符合您的需求,您可以自己编写IHostedService。我使用了下面的WorkerService,它比IApplicationLifetime.StopApplication()更具优势。因为async void在线程池上运行连续操作,因此可以使用AppDomain.CurrentDomain.UnhandledException处理错误,并将以错误退出代码终止。有关更多详细信息,请参见XML注释。
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);
    }
}

0

简短回答

你没有等待从ExecuteAsync方法返回的Task。如果你等待它,你就会看到你第一个例子中的异常。

详细回答

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

首先,为什么在await之前的异常会立即传播。

Task DoSomethingAsync()
{
    throw new Exception();
    await Task.Delay(1);
}

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

在第二个例子中:

Task DoSomethingAsync()
{
    await Task.Delay(1);
    throw new Exception();
}

编译器已经生成了涉及到 continuation 的样板代码。因此,您调用方法 DoSomethingAsync。该方法将立即返回。您没有等待它,所以您的代码会立即继续执行。样板代码已经在 await 语句下面的一行代码上创建了一个 continuation。该 continuation 将被称为“不是您的代码”的东西,并会获得异常(封装在异步任务中)。现在,直到该任务被解包之前,该任务将不会执行任何操作。
未观察到的任务想要让别人知道出了什么问题,所以在 finalizer 中有一个技巧。如果任务未被观察到,finalizer 将抛出异常。因此,在这种情况下,任务可以传播其异常的第一个点是在其被垃圾回收之前进行 finalizer 处理。
您的进程不会立即崩溃,但它将在任务被垃圾回收之前崩溃。
阅读材料:

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