c# - 如果在 'await' 之后抛出,则从任务中抛出的异常被吞没

标签 c# .net-core async-await task-parallel-library background-service

我正在使用 .NET 的 HostBuilder 编写后台服务.
我有一门课叫 MyService实现 BackgroundService ExecuteAsync方法,我在那里遇到了一些奇怪的行为。
里面方法我await某个任务,以及在 await 之后抛出的任何异常被吞下,但在 await 之前抛出异常终止进程。
我在各种论坛(堆栈溢出、msdn、中等)上查看了在线信息,但找不到这种行为的解释。

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;博士;

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

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

解释

这与 await 关系不大本身。之后抛出的异常将冒泡给调用者。处理它们的是调用者,或者不是。
ExecuteAsyncBackgroundService 调用的方法这意味着该方法引发的任何异常都将由 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 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()

RunAsync() Main() 中用于启动托管服务的方法实际上调用了主机的 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();

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

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

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

结论

不要让异常逃逸 ExecuteAsync .捕获它们并妥善处理它们。选项是:
  • 记录并“忽略”它们。这将使 BackgroundService 无效,直到用户或某些其他事件调用应用程序关闭。退出ExecuteAsync不会导致应用程序退出。
  • 重试操作。这可能是简单服务中最常见的选项。
  • 在排队或定时服务中,丢弃出错的消息或事件并移至下一个。这可能是最有弹性的选择。可以检查错误消息,将其移至“死信”队列,重试等。
  • 明确要求关机。为此,请添加 IHostedApplicationLifetTime接口(interface)作为依赖并调用StopAsync来自 catch堵塞。这将调用 StopAsync也适用于所有其他后台服务

  • 文档

    托管服务的行为和BackgroundServiceImplement 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)}.");
            }
        }
    

    关于c# - 如果在 'await' 之后抛出,则从任务中抛出的异常被吞没,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/56871146/

    相关文章:

    c# - 在 C# 中显示完整的 SSH shell 输出,包括登录消息和提示

    c# - 我想要一种基于 Entity Framework 中的 where 子句更新一系列记录的方法,而不使用 ToList() 和 foreach

    .net - 从代码调用 Azure 函数

    c# - dotnet publish profile CLI 与 Visual Studio 的行为不同

    python - 与同步调用结合使用时,等待语句的顺序在 Python 中是否重要?

    c# - 如何计算两个日期时间之间的百分比

    c# - 如何在 C# 中使用 List<int> 作为 SQL 参数

    loops - 循环中的异步和等待多个任务,如何返回单个任务

    c# - asp.net BLL 类中的各种缓存选项

    javascript - 异步等待使用和错误处理问题