簡體   English   中英

從任務拋出的異常被吞下,如果在“等待”之后拋出

[英]Exception thrown from task is swallowed, if thrown after 'await'

我正在使用 .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本身無關。 在它之后拋出的異常會冒泡給調用者。 調用者處理它們,或者不處理它們。

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() 中用於啟動托管服務的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() IHost.StartAsync()中調用,因此應放置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)}.");
        }
    }

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

簡答

您不是在等待從ExecuteAsync方法返回的Task 如果您等待它,您就會觀察到第一個示例中的異常。

長答案

所以這是關於“被忽略”的任務以及該異常何時傳播。

首先是等待之前的異常立即傳播的原因。

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

await 語句之前的部分同步執行,在您從中調用它的上下文中。 堆棧保持不變。 這就是您在調用站點上觀察異常的原因。 現在,您沒有對這個異常做任何事情,所以它終止了您的進程。

在第二個例子中:

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

編譯器制作了涉及延續的樣板代碼。 所以你調用方法DoSomethingAsync 該方法立即返回。 您不會等待它,因此您的代碼會立即繼續。 樣板文件延續了await語句下方的代碼行。 該延續將被稱為“不是您的代碼的東西”,並將獲得異常,並包裝在異步任務中。 現在,該任務不會做任何事情,直到它被解開。

未觀察到的任務想讓某人知道出了什么問題,因此終結器中有一個技巧。 如果任務未被觀察到,終結器將拋出異常。 所以在這種情況下,任務可以傳播其異常的第一個點是它完成時,在它被垃圾收集之前。

您的進程不會立即崩潰,但它會在任務被垃圾收集之前“崩潰”。

閱讀材料:

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM