如果在 'await' 之后抛出,则从任务抛出的异常被吞噬

Exception thrown from task is swallowed, if thrown after 'await'

我正在使用 .NET HostBuilder 编写后台服务。 我有一个名为 MyService 的 class 实现了 BackgroundService ExecuteAsync 方法,我在那里遇到了一些奇怪的行为。 在方法内部我 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);
        }
    }

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

TL;DR;

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

也不要等太久才开始那里的第一个异步操作

说明

这与await本身关系不大。之后抛出的异常将冒泡到调用者。处理或不处理它们的是 caller

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;
    }

Nothing waits返回的任务,所以这里不会抛出任何东西。检查 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 之前的所有内容都在原始线程上运行。除非得到处理,否则扔在那里的任何东西都会使应用程序崩溃。由于在 Main() 中调用了 IHost.RunAsync()IHost.StartAsync(),因此应该放置 try/catch 块。

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

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

时,第一个操作才会冒泡

结论

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

  • 记录并 "ignore" 它们。这将使 BackgroundService 无法运行,直到用户或其他一些事件要求关闭应用程序。退出 ExecuteAsync 不会导致应用程序退出。
  • 重试该操作。这可能是简单服务的最常见选项。
  • 在排队或定时服务中,丢弃出错的消息或事件并移至下一个。这可能是最具弹性的选择。可以检查错误消息,将其移至 "dead letter" 队列,重试等
  • 明确要求关机。为此,请从 catch 块中添加 IHostedApplicationLifetTime interface as a dependency and call StopAsync。这也会在所有其他后台服务上调用 StopAsync

文档

托管服务和 BackgroundService 的行为在 Implement background tasks in microservices with IHostedService and the BackgroundService class and 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)}.");
        }
    }

简答

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

长答案

所以这是关于 'ignored' 任务以及异常传播的时间。

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

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

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

在第二个例子中:

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

编译器制作了涉及延续的样板代码。所以你调用方法DoSomethingAsync。该方法 returns 即时。你不需要等待它,所以你的代码会立即继续。样板文件延续了 await 语句下方的代码行。该延续将被称为 'something that is not your code' 并将获得异常,包装在一个异步任务中。现在,该任务将不会执行任何操作,直到它被解包。

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

您的进程不会立即崩溃,但会崩溃'before'任务已被垃圾回收。

阅读material:

您不必使用 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);
    }
}