Небольшой обзор стандартных средств запуска бэкграунд-задач в аспнет приложениях — что есть, чем отличается, как пользоваться. Встроенный механизм запуска таких задач строится вокруг интерфейса IHostedService и метода-расширения для IServiceCollection — AddHostedService. Но есть несколько способов реализовать фоновые задачи через этот механизм (и ещё несколько неочевидных моментов поведения этого механизма).

Зачем запускать фоновые задачи

Есть 2 глобальных сценария фоновых задач:

  • Однокатный запуск фоновой задачи при старте приложения с ожиданием начала обработки запросов или до него. Например, можно проводить миграцию данных до начала обработки запросов, прогревать кэши или другие части приложения для решения проблемы обработки первых запросов. Ещё может понадобится дождаться старта приложения — чтобы зажечь beacon сервиса в service discovery, получив информацию о прослушиваемых адресах

  • Периодический регулярный запуск фоновой задачи — это может быть health check, отправка телемитрии сервиса, инвалидация кэша

aspnet позволяет отделить фоновые сервисы и переиспользовать их в разных приложениях, что даст универсальный механизм для таких операций в разных сервисах. Во всех примерах ниже для регистрации нового фонового сервиса используется метод ServiceCollectionHostedServiceExtensions.AddHostedService:

services.AddHostedService<MyHostedService>();

Собственная реализация IHostedService

Интерфейс IHostedService предоставляет 2 метода:

public interface IHostedService
{
    // Вызывается, когда приложение готово запустить фоновую службу
    Task StartAsync(CancellationToken stoppingToken);
    // Вызывается, когда происходит нормальное завершение работы узла приложения.
    Task StopAsync(CancellationToken stoppingToken);
}

Что важно знать при реализации интерфейса? Все IHostedService запускаются последовательно, а вызов StartAsync блокирует запуск остальной части приложения. Поэтому в StartAsync не должно быть длинных блокирующих операций, если вы только действительно не хотите отложить запуск приложения до завершения этой операции (например, при миграции БД):

Именно это является особенностью и мотивацией реализовать фоновые операции через собственную реализацию IHostedService — если вам нужен полный контроль над запуском и остановкой фонового сервиса. Если это не так важно , то достаточно отнаследоваться от класса BackgroundService.

Ещё пара важных для реализации моментов:

  • У CancellationToken в StopAsync есть по-умолчанию 5 секунд для корректного завершения

Как правильно заметил в комментарии @kuda78CancellationToken в StopAsync общий для всего хоста и сервисов, и HostedSerice останавливаются последовательно. То есть 5 секунд на завершение даётся всему хосту, а не каждому сервису. Если какой-то из IHostedSerice выполнял StopAsync 4 секунды, то следующим фоновым сервисам останется на корректное завершение только одна. Если приложению и фоновым сервисам точно необходимо больше времени для корректного завершения работы, то таймаут можно переопределить, задав явно значение HostOptions.ShutdownTimeout.

  • StopAsync может вообще не быть вызван при неожиданном завершении приложения. Поэтому, например, недостаточно гасить beacon только в этом методе.

Общая реализация фоновой периодической задачи в этом случае может выглядеть примерно так:

public class MyHostedService : IHostedService
{
    private readonly ISomeBusinessLogicService someService;
 
    public MyHostedService(ISomeBusinessLogicService someService)
    {
        this.someService = someService;
    }
 
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Не блокируем поток выполнения: StartAsync должен запустить выполнение фоновой задачи и завершить работу
        DoSomeWorkEveryFiveSecondsAsync(cancellationToken);
        return Task.CompletedTask;
    }
 
    private async Task DoSomeWorkEveryFiveSecondsAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await someService.DoSomeWorkAsync();
            }
            catch (Exception ex)
            {
                // обработка ошибки однократного неуспешного выполнения фоновой задачи
            }
 
            await Task.Delay(5000, stoppingToken);
        }
    }
 
    public Task StopAsync(CancellationToken cancellationToken)
    {
        // Если нужно дождаться завершения очистки, но контролировать время, то стоит предусмотреть в контракте использование CancellationToken
        await someService.DoSomeCleanupAsync(cancellationToken);
        return Task.CompletedTask;
    }
}

Наследование от BackgroundService

BackgroundService — это абстрактный класс, котоырй реализует IHostedService, сам обрабатывает запуск и остановку, предоставляя 1 абстрактный метод ExecuteAsync:

public abstract class BackgroundService : IHostedService, IDisposable
{
    private Task _executingTask;
    private readonly CancellationTokenSource _stoppingCts = new CancellationTokenSource();

    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

    public virtual Task StartAsync(CancellationToken cancellationToken)
    {
        _executingTask = ExecuteAsync(_stoppingCts.Token);
        return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
    }

    public virtual async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_executingTask == null)
            return;

        try
        {
            _stoppingCts.Cancel();
        }
        finally
        {
            await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken));
        }
    }

    public virtual void Dispose() => _stoppingCts.Cancel();
}

StartAsync и StopAsync всё ещё можно перегрузить. Реализация фоновых задач через BackgroundService подходит для всех сценариев, где не нужно блокировать запуск приложения до завершения выполнения операции.

Общая реализация фоновой периодической задачи:

public class MyHostedService : BackgroundService
{
    private readonly ISomeBusinessLogicService someService;
 
    public MyHostedService(ISomeBusinessLogicService someService)
    {
        this.someService = someService;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Выполняем задачу пока не будет запрошена остановка приложения
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await someService.DoSomeWorkAsync();
            }
            catch (Exception ex)
            {
                // обработка ошибки однократного неуспешного выполнения фоновой задачи
            }
 
            await Task.Delay(5000);
        }
 
        // Если нужно дождаться завершения очистки, но контролировать время, то стоит предусмотреть в контракте использование CancellationToken
        await someService.DoSomeCleanupAsync(cancellationToken);
    }
}

Когда и как запускается IHostedService

Занятный факт, правильный ответ — "зависит". От версии .net.

В .NET Core 2.x IHostedService запускались после конфигурирования и старта Kestrel, то есть после того, как приложение начинает слушать порты для приема запросов. Это значит, что, например, мы можем получить в фоновом сервисе объект IServer и IServerAddressesFeature и быть уверенными, что в момент запуска фонового сервиса список прослушиваемых адресов будет уже настроен. Ещё это значит, что на момент запуска IHostedService приложение уже может отвечать на запросы клиентов, поэтому нельзя гарантировать, что на момент обработки запроса какой-то из IHostedService уже запущен.

В .NET Core 3.0 с переходом на новую абстракцию IHost (на самом деле универсальный узел появился уже в .net core 2.1) поведение изменилось — теперь Kestrel начал запускаться как отдельный IHostedService последним после всех остальных IHostedService. Фактически фоновые сервисы запускаются до метода Statup.Configure(). Теперь можно гарантировать, что на момент начала прослушивания портов и обработки запросов все другие фоновые сервисы запущены, а ещё можно не начинать обработку запросов до завершения запуска одного из фоновых сервисов с помощью переопределения StartAsync.

Иллюстрация от Andrew Lock https://twitter.com/andrewlocknet
Иллюстрация от Andrew Lock https://twitter.com/andrewlocknet

В .NET 6 всё снова немного поменялось. Появился Minimal hosting API, в котором нет дополнительных абстрацией в виде Startup.cs, а приложение конфигурируется явно с помощью нового класса WebApplication. Тут надо отметить, что новое апи включено по-умолчанию в шаблон аспнет приложения, поэтому для новых проектов из коробки будет использоваться именно оно. Все IHostedService в этом случае запускаются, когда вы вызываете WebApplication.Run(), то есть, уже после того, как вы настроили приложение и список прослушиваемых адресов. Подробнее об этом написано в issue на github.

Фактически это значит, что поведение и доступные в IHostedService параметры могут меняться в зависимости от версии .net и способа хостинга, и не может полагаться внутри сервиса на то, что Kestrel уже сконфигурирован и запущен. Поэтому, если фоновый сервис работает с конфигурацией Kestrel, то нужен способ дождаться его запуска внутри IHostedService.

Ожидание запуска Kestrel внутри IHostedService

В asp.net core начиная с версии 3.0 появился сервис, который позволяет получить уведомления о том, что приложение завершило запуск и начало обрабатывать запросы — это IHostApplicationLifetime.

public interface IHostApplicationLifetime
{
    CancellationToken ApplicationStarted { get; }
    CancellationToken ApplicationStopping { get; }
    CancellationToken ApplicationStopped { get; }
    void StopApplication();
}

CancellationToken даёт удобный механизм безопасного запуска колбеков при возникновении события:

lifetime.ApplicationStarted.Register(() => DoSomeAction());

Благодаря этому мы можем дождаться запуска приложения. Но в ожидании запуска нам нужно обработать ситуацию, когда возникают проблемы с стартом — тогда приложение никогда не запустится, а ожидающий старта метод не завершится. Чтобы это исправить достататочно ждать не только ApplicationStarted, но и обрабаывать событие для stoppingToken, приходящего в ExecuteAsync. Вот как будет выглядеть полный пример фонового сервиса, который ожидает запуск приложения и корректно обрабатывает ошибки запуска:

public class MyHostedService : BackgroundService
{
    private readonly ISomeBusinessLogicService someService;
    private readonly IHostApplicationLifetime lifetime;
 
    public MyHostedService(ISomeBusinessLogicService someService, IHostApplicationLifetime lifetime)
    {
        this.lifetime = lifetime;
        this.someService = someService;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
       if (!await WaitForAppStartup(lifetime, stoppingToken))
            return;
 
        // Приложение запущено и готово к обработке запросов
 
        // Выполняем задачу пока не будет запрошена остановка приложения
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await someService.DoSomeWorkAsync();
            }
            catch (Exception ex)
            {
                // обработка ошибки однократного неуспешного выполнения фоновой задачи
            }
 
            await Task.Delay(5000);
        }
 
        // Если нужно дождаться завершения очистки, но контролировать время, то стоит предусмотреть в контракте использование CancellationToken
        await someService.DoSomeCleanupAsync(cancellationToken);
    }
 
    static async Task<bool> WaitForAppStartup(IHostApplicationLifetime lifetime, CancellationToken stoppingToken)
    {
        // ???? Создаём TaskCompletionSource для ApplicationStarted
        var startedSource = new TaskCompletionSource();
        using var reg1 = lifetime.ApplicationStarted.Register(() => startedSource.SetResult());
 
        // ???? Создаём TaskCompletionSource для stoppingToken
        var cancelledSource = new TaskCompletionSource();
        using var reg2 = stoppingToken.Register(() => cancelledSource.SetResult());
 
        // Ожидаем любое из событий запуска или запроса на остановку
        Task completedTask = await Task.WhenAny(startedSource.Task, cancelledSource.Task).ConfigureAwait(false);
 
        // Если завершилась задача ApplicationStarted, возвращаем true, иначе false
        return completedTask == startedSource.Task;
    }
}

Обработка исключений в IHostedService

Запуск IHostedService происходит в методе Host.StartAsync (код в .net 6):

foreach (IHostedService hostedService in _hostedServices)
{
    // Fire IHostedService.Start
    await hostedService.StartAsync(combinedCancellationToken).ConfigureAwait(false);

    if (hostedService is BackgroundService backgroundService)
    {
        _ = TryExecuteBackgroundServiceAsync(backgroundService);
    }
}

Если во время запуска фонового сервиса в методе StartAsync происходит исключение, то оно не обрабатывается снаружи и запуска хоста приложения прерывается. Если же в IHostedService.StartAsync запускается асинхронная операция без ожидания, то исключения из такого метода никогда не будут возвращены наружу и хост не узнают об ошибке в фоновом сервисе. В этом случае следует предусмотреть самостоятельную обработку исключений.

В наследниках BackgroundService поведение при необработанных исключениях в методе ExecuteAsync отличается в зависимости от версии .net.

В .NET до 6.0 необработанные исключения в BackgroundService просто терялись, а приложение продолжало работу. Начиная с .NET 6 при по-умолчанию необработанное исключение из BackgroundService будет залогировано, а приложение завершит работу. За это отвечает тот самый метод TryExecuteBackgroundServiceAsync, который выполняется для наследников BackgroundService :

private async Task TryExecuteBackgroundServiceAsync(BackgroundService backgroundService)
{
    // backgroundService.ExecuteTask может остаться неинициализированным 
    //(например, если в наследнике BackgroundService при переопределении StartAsync не был вызван base.StartAsync)
    Task backgroundTask = backgroundService.ExecuteTask;
    if (backgroundTask == null)
        return;

    try
    {
        await backgroundTask.ConfigureAwait(false);
    }
    catch (Exception ex)
    {
        // Когда хост останавливается, он посылает сигнал для остановки фоновых служб.
        // Это не является ошибочной ситуацией, поэтому логировать это как ошибку не нужно.
        if (_stopCalled && backgroundTask.IsCanceled && ex is OperationCanceledException)
            return;

        _logger.BackgroundServiceFaulted(ex);
        if (_options.BackgroundServiceExceptionBehavior == BackgroundServiceExceptionBehavior.StopHost)
        {
            _logger.BackgroundServiceStoppingHost(ex);
            _applicationLifetime.StopApplication();
        }
    }
}

Чтобы переопределить поведение и не останавливать хост в случае исключений в BackgroundService можно при конфигурации сервисов использовать настройку HostOptions.BackgroundServiceExceptionBehavior:

Host.CreateBuilder(args)
    .ConfigureServices(services =>
    {
        services.Configure<HostOptions>(hostOptions =>
        {
            hostOptions.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
        });
    });

Сторонние библиотеки для фоновых задач

Ещё одно популярное (57 миллионов скачиваний) решение для фоновых задач в дотнет — это Hangfire, библиотека для настройки, запуска и хранения фоновых задач с бесплатной версией для коммерческого использования, большим количеством настроек и отдельной админкой задач.

Что почитать о фоновых задачах в asp.net

Материалы собраны из статей Microsoft docs, статей Andew Lock и выступления Scott Sauber на Rome .NET Conference:

Комментарии (9)


  1. makar_crypt
    03.04.2022 22:57
    +1

    если мы как вы пишите "не блокируем" запускаем Task и возвращаем Task.CompletedTask . Как программа понимает что её не нужно завершаться ?

    Какой то внутренний механизм слежки за пустотой TaskFactory пулом?


    1. Vadimyan Автор
      03.04.2022 23:04

      Сам IHost при запуске IHostedService делает awaitи будет ждать завершения метода StartAsync. То есть, если внутри сервиса заэвейтит задачу await DoSomeWorkEveryFiveSecondsAsync(cancellationToken);, то IHost будет бесконечно ждать старта сервиса и никогда не запустит остальные.

      Ну, или запустит, если внутри StartAsync завершится через какое-то время, например, прогрев кэши или завершив другое нужное нам перед началом обработки запросов действие.

      Речь как раз о том, что если нам не нужно блокировать дальнейший запуск хоста, то внутри StartAsync достаточно запустить задачу без ожидания и следить за переданным CancellationToken . В этом случае await StartAsync получит Task.CompletedTask и продолжит дальнейшую инициализацию.


      1. slepmog
        04.04.2022 00:12

        Как-то это всё очень странно.


        И вы, и официальная документация рекомендуют fire and forget по типу #1, т.е. с игнорированием исключений. Причём если исключение выброшено из синхронной части вызываемого метода, оно будет передано наверх, а если микросекундой позже — проигнорировано.


        Мне кажется, это применимо (?) только если из StartAsync вызывается именно ExecuteAsync, как в документации, а не что-то другое.


        1. Vadimyan Автор
          04.04.2022 01:16

          Да, async/await работает именно так. Вижу здесь 2 возможных разреза вопроса. Но для начала хочу показать код запуска HosterService внутри класса Host:

          foreach (IHostedService hostedService in _hostedServices) 
          {   
            // Fire IHostedService.Start    
            await hostedService.StartAsync(combinedCancellationToken).ConfigureAwait(false);       
          
            if (hostedService is BackgroundService backgroundService)
              _ = TryExecuteBackgroundServiceAsync(backgroundService);
          }


          1. Выполняет ли HostedService разовую работу при инициализации приложения или же он запускает периодическое выполнение задачи как в примере из статьи.
          1.1. Если выполняет разовую работу при инициализации, то важным для await будет вопрос о том можно ли начать обработку запросов ДО завершения работы HostedService.
          1.1.1. Если нет, то мы должны ожидать асинхронных вызовов внутри StartAsyncчтобы сработал внешний await в Host. В этом случае await hostedService.StartAsync получит задачу, которую будет ожидать.
          1.1.2. Если да, то чтобы хост смог запуститься понадобится не ожидать асинхронные вызовы внутри hostedService.StartAsync, и отдать из метода Task.CompletedTask чтобы выполнение старта хоста продолжилось.
          1.2. Если HostedService запускает периодическое выполнение задачи, то мы не можем ждать внутри StartAsync, иначе хост просто никогда не начнет обрабатывать вызовы потому что будет ждать завершения StartAsync.

          Но на самом деле сценарии 1.2 или 1.1.2 означают, что нам вообще не нужно использовать свою реализацию IHostedService, а достаточно будет отнаследоваться от BackgroundService.

          2. Может ли приложение продолжить работать при ошибке в HostedService.
          2.1. Если да (например, HostedService подчищал старые кэши, отправлял дополнительную необязательную телеметрию), то какой-то особенной обработки исключений нам не нужно.
          2.2. Если нет, то для gracefully shutdown стоит использовать упомянутый в статье IHostApplicationLifetime.

          Но на самом деле в .net 6 хост будет останавлён при возникновении исключения в ExecuteAsync. Именно за это отвечает метод TryExecuteBackgroundServiceAsync, который был в начале комментария. Вот статья об этом изменении: Breaking changes / Unhandled exceptions from a BackgroundService. Чтобы переопределить это поведение и не останавливать хост в случае исключений в BackgroundService нужно сконфигурировать хост для игнорирования таких исключений:

          Host.CreateBuilder(args)
              .ConfigureServices(services =>
              {
                  services.Configure<HostOptions>(hostOptions =>
                  {
                      hostOptions.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
                  });
              });


  1. kuda78
    04.04.2022 10:59
    +1

    У CancellationToken в StopAsync есть 5 секунд для корректного завершения

    Важное уточнение, что это время задается HostOptions.ShutdownTimeout и задается для всего хоста целиком я не для каждого сервиса.

    Метод ExecuteAsync в некотором роде нарушает контракт.
    При получении токена отмены он говорит "я завершился" а не OperationCanceledException.
    Я бы предложил следующее видение кода:

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
      await WaitForAppStartup(lifetime, stoppingToken);
      ....
    }
    
    static async Task WaitForAppStartup(IHostApplicationLifetime lifetime, CancellationToken stoppingToken)
    {
      var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
      using var reg1 = lifetime.ApplicationStarted.Register(() => tcs.TrySetResult());
      using var reg2 = stoppingToken.Register(() => tcs.TrySetCanceled());
      await tcs.Task;
    }


    1. Vadimyan Автор
      04.04.2022 17:52

      Если речь о том, что на самом деле таймаут общий и StopAsync последовательный, поэтому когда 1 HostedService потратил на остановку 4 секунды, то второму осталась всего одна, то да, тут я поправлю статью, это тонкий и важный момент.

      Что касается второй части — не совсем вижу тут проблему. Если в моем коде приложение стартовало и сработал lifetime.ApplicationStarted.Register, то проблемы нет. Если приложение не стартовало и сработал stoppingToken.Register, то в моем коде всё равно вернется корректный результат WaitForAppStartup без ошибок, а в вашем внутри ExecuteAsync будет исключение System.Threading.Tasks.TaskCanceledException: A task was canceled. — почему этот сценарий лучше?


      1. kuda78
        05.04.2022 08:19

        Сугубо личное мнение по второй части в порядке уменьшения важности:

        • Придерживаясь подобного контракта мы всегда можем получить ответ на вопрос "задача успела завершится штатно до получения токена отмены или нет". Исключением является задача, в которой обрабатывается некоторый поток событий.

        • Используется только один TaskCompletionSource (Экономия на спичках)

        • Код получается более лаконичным.


  1. makar_crypt
    04.04.2022 17:49

    Посмотрел код

    Запуск когда не держим таск:

    if (hostedService is BackgroundService backgroundService)

    _ = TryExecuteBackgroundServiceAsync(backgroundService);

    Инит:

    IHost host = Host.CreateDefaultBuilder(args).ConfigureServices
    
    services.AddHostedService<MyService>();

    Внутри

    await host.RunAsync();

    try{  
    await host.StartAsync(token).ConfigureAwait(false);  
    await host.WaitForShutdownAsync(token).ConfigureAwait(false);}

    Не увидел как этот console application не закрывается после окончания main?

    Нету никакого массива тасков, никакого массива services , даже никакой проверки что "запущен хотябы один не системный task в приложении" потипу:

    while (Task.Factory.RunCount > 0) {

    thread.sleep(1)

    }


    1. kuda78
      05.04.2022 08:23
      +2

      await host.RunAsync();

      внутри реализован как

      await host.StartAsync(token).ConfigureAwait(false);
      await host.WaitForShutdownAsync(token).ConfigureAwait(false);

      Т.е. внутри него и происходит ожидание сигнала, что хост завершил свою работу.