Исключение, выброшенное из задачи, проглатывается, если выбрано после 'await'

Я пишу фоновую службу с использованием .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);
        }
    }

Я ожидаю, что оба исключения прервут процесс.


person TheDotFestClub    schedule 03.07.2019    source источник
comment
Это интересно, но мне любопытно, почему это так ведет себя.   -  person TheDotFestClub    schedule 03.07.2019
comment
Пожалуйста, см. этот ответ casperOne, объясняет несколько способов обработки исключений по задачам ...   -  person zaggler    schedule 03.07.2019
comment
Его ответ не объясняет, почему первого проглатывают, а второго бросают.   -  person TheDotFestClub    schedule 03.07.2019
comment
Вы в этом уверены? Кто звонит ExecuteAsync?   -  person Panagiotis Kanavos    schedule 03.07.2019
comment
Я предпочитаю этот ответ, потому что он объясняет концепцию сборки мусора.   -  person Jesse de Wit    schedule 03.07.2019
comment
@JessedeWit, однако, дело не в сборщике мусора. Это BackgroundService, что означает, что он, вероятно, жив, пока приложение   -  person Panagiotis Kanavos    schedule 03.07.2019
comment
@TheDotFestClub, как вы выполняете эту фоновую службу? Вы используете RunAsync, StartAsync?   -  person Panagiotis Kanavos    schedule 03.07.2019
comment
@PanagiotisKanavos Правда, вместо этого речь идет об очереди финализатора. Однако сборщик мусора может перемещать объекты в очередь финализатора. Финализаторы (которые также называются деструкторами) используются для выполнения любой необходимой окончательной очистки, когда экземпляр класса собирается сборщиком мусора. source   -  person Jesse de Wit    schedule 03.07.2019
comment
@PanagiotisKanavos ExecuteAsync вызывается из базового класса, который является кодом .net. Вызывается, но не ожидается в BackgroundService.StartAsync   -  person TheDotFestClub    schedule 03.07.2019
comment
@PanagiotisKanavos Я использую RunConsoleAsync   -  person TheDotFestClub    schedule 03.07.2019
comment
@TheDotFestClub, если его не ждут, проблема в том, что код вызова уже был возвращен до того, как было сгенерировано исключение.   -  person Ant P    schedule 03.07.2019
comment
@JessedeWit, дело не в этом. Это действительно о том, как эти методы называются. Да, в конце речь идет о GC, но только потому, что инфраструктура хостинга работает так, как она.   -  person Panagiotis Kanavos    schedule 03.07.2019


Ответы (3)


TL; DR;

Не позволяйте исключениям выходить из 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 реализации Host. Код показывает, что происходит:

    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, возобновится после завершения этой задачи.

От хоста к основному ()

Метод As используется в Main () для запуска размещенных служб, на самом деле вызывает StartAsync Host, но не 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();

or

await host.RunConsoleAsync();

Это означает, что все, вплоть до первого реального await в списке BackgroundService объектов, выполняется в исходном потоке. Все, что брошено туда, приведет к остановке приложения, если не будет обработано. Поскольку IHost.RunAsync() или IHost.StartAsync() вызываются в Main(), именно там должны быть размещены блоки try/catch.

Это также означает, что размещение медленного кода перед первой реальной асинхронной операцией может задержать выполнение всего приложения.

Все, что после этой первой асинхронной операции, будет продолжать выполняться в потоке пула потоков. Вот почему исключения, выдаваемые после этой первой операции, не всплывают до тех пор, пока размещенные службы не отключатся с помощью вызова IHost.StopAsync или пока какие-либо осиротевшие задачи не получат GCd.

Заключение

Не позволяйте исключениям убегать ExecuteAsync. Ловите их и обращайтесь с ними должным образом. Возможные варианты:

  • Регистрируйте и «игнорируйте» их. При этом BackgroundService будет оставаться в нерабочем состоянии до тех пор, пока пользователь или какое-либо другое событие не вызовет завершение работы приложения. Выход из ExecuteAsync не вызывает выхода из приложения.
  • Повторите операцию. Это, наверное, самый распространенный вариант простого сервиса.
  • В службе, поставленной в очередь или по времени, отбросьте сообщение или событие, в котором произошел сбой, и перейдите к следующему. Это, наверное, самый надежный вариант. Сообщение с ошибкой можно проверить, переместить в очередь "недоставленных писем", повторить попытку и т. Д.
  • Явно попросите выключить компьютер. Для этого добавьте IHostedApplicationLifetTime в качестве зависимости и вызовите StopAsync из блока catch. Это вызовет 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)}.");
        }
    }
person Panagiotis Kanavos    schedule 03.07.2019
comment
УДИВИТЕЛЬНОЕ объяснение! большое спасибо. Если у вас есть рекомендуемые источники для получения дополнительной информации по этой теме, я был бы очень признателен. - person TheDotFestClub; 03.07.2019
comment
@TheDotFestClub ссылки на первоисточник, к сожалению, и болезненный опыт. Примеры документации показывают, как создать BackgroundService, но не объясняют, как он себя ведет, или почему их примеры написаны таким образом. Я потратил довольно много времени, пытаясь понять, почему мое приложение закрывается после того, как ExecuteAsync нормально завершилось - пока я не понял, что что-то должно вызвать Stop. - person Panagiotis Kanavos; 03.07.2019
comment
This means that everything up to the first real await in the list of BackgroundService objects runs on the original thread. Anything thrown there will bring down the application unless handled. - но исключение обработано. Он захватывается конечным автоматом async и помещается в возвращенный Task. - person Stephen Cleary; 03.07.2019
comment
@StephenCleary также возвращается в Host.StartAsync, где ожидается. Это означает, что если какая-либо из служб выйдет из строя, Host.StartAsync также выйдет из строя, и исключение будет всплывать дальше. В конце концов, это исключение достигнет RunAsync или RunConsoleAsync, который вызывается из самого Main() с помощью await host.RunAsync() или RunConsoleAsync. И я не нашел точного кода для последнего RunAsync до сих пор - person Panagiotis Kanavos; 03.07.2019
comment
Операция относится к ExecuteAsync, у которой действительно есть поведение, описанное операцией (синхронные исключения распространяются; асинхронные - нет). Мне кажется, что это ошибка в .NET Core; по крайней мере, синхронное завершение ExecuteAsync обрабатывается иначе, чем асинхронное завершение. - person Stephen Cleary; 03.07.2019
comment
@StephenCleary Я потратил последний час на поиски кода в Github. Я даже думать больше не могу. Я не могу снова начинать гоняться за звонками. Некоторые из этих вещей я обнаружил на собственном горьком опыте, некоторые ошибки я понял только сейчас, просматривая код. ExecuteAsync документация ничего не говорит об исключениях. - person Panagiotis Kanavos; 03.07.2019
comment
@StephenCleary PS статья о размещенных сервисах должна быть разбита как минимум на 3 разных статьи. Он пытается показать слишком много вещей одновременно. Это заканчивается слишком мелко и слишком запутанно. - person Panagiotis Kanavos; 03.07.2019
comment
Я потратил довольно много времени на чтение вопросов github об этой проблеме. Это было лучшее объяснение, которое я нашел до сих пор. - person Gustav Wengel; 01.06.2020

Вам не нужно использовать 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);
    }
}
person Kyle McClellan    schedule 20.07.2021

Короткий ответ

Вы не ждете Task, возвращаемого методом ExecuteAsync. Если бы вы этого ждали, вы бы наблюдали исключение из вашего первого примера.

Длинный ответ

Итак, речь идет о «игнорируемых» задачах и о том, когда это исключение распространяется.

Во-первых, причина, по которой исключение перед ожиданием распространяется мгновенно.

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

Часть перед оператором await выполняется синхронно в контексте, из которого вы ее вызывали. Стек остается нетронутым. Вот почему вы наблюдаете исключение на сайте вызова. Теперь вы ничего не сделали с этим исключением, поэтому он завершает ваш процесс.

Во втором примере:

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

Компилятор создал шаблонный код, включающий продолжение. Итак, вы вызываете метод DoSomethingAsync. Метод возвращается мгновенно. Вы не ждете этого, поэтому ваш код продолжается мгновенно. Шаблон стал продолжением строки кода под оператором await. Это продолжение будет называться «что-то, что не является вашим кодом» и получит исключение, заключенное в асинхронную задачу. Теперь эта задача ничего не сделает, пока она не будет развернута.

Незаблюдаемые задачи хотят, чтобы кто-то знал, что что-то пошло не так, поэтому в финализаторе есть хитрость. Финализатор выдаст исключение, если задача не наблюдалась. Итак, в этом случае первая точка, в которой задача может распространить свое исключение, - это когда она завершается, прежде чем она будет собрана мусором.

Ваш процесс не выйдет из строя мгновенно, но он выйдет из строя «до того, как» задача будет обработана сборщиком мусора.

Материал для чтения:

person Jesse de Wit    schedule 03.07.2019