Небольшой обзор стандартных средств запуска бэкграунд-задач в аспнет приложениях — что есть, чем отличается, как пользоваться. Встроенный механизм запуска таких задач строится вокруг интерфейса 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 секунд для корректного завершения
Как правильно заметил в комментарии @kuda78—
CancellationToken
в 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
.
В .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:
Jeow Li Huan: Background tasks with hosted services in ASP.NET Core
Scott Sauber: Rome .NET Conference – The Background on Background Tasks in .NET 6
Andrew Lock: Running async tasks on app startup in ASP.NET Core 3.0
Andew Lock: Controlling IHostedService execution order in ASP.NET Core 3.x
Andrew Lock: Waiting for your ASP.NET Core app to be ready from an IHostedService in .NET 6
Breaking changes: Unhandled exceptions from a BackgroundService
Andrew Lock: Extending the shutdown timeout setting to ensure graceful IHostedService shutdown
Комментарии (9)
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; }
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.
— почему этот сценарий лучше?kuda78
05.04.2022 08:19Сугубо личное мнение по второй части в порядке уменьшения важности:
Придерживаясь подобного контракта мы всегда можем получить ответ на вопрос "задача успела завершится штатно до получения токена отмены или нет". Исключением является задача, в которой обрабатывается некоторый поток событий.
Используется только один TaskCompletionSource (Экономия на спичках)
Код получается более лаконичным.
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)
}
kuda78
05.04.2022 08:23+2await host.RunAsync();
внутри реализован как
await host.StartAsync(token).ConfigureAwait(false); await host.WaitForShutdownAsync(token).ConfigureAwait(false);
Т.е. внутри него и происходит ожидание сигнала, что хост завершил свою работу.
makar_crypt
если мы как вы пишите "не блокируем" запускаем Task и возвращаем Task.CompletedTask . Как программа понимает что её не нужно завершаться ?
Какой то внутренний механизм слежки за пустотой TaskFactory пулом?
Vadimyan Автор
Сам
IHost
при запускеIHostedService
делаетawait
и будет ждать завершения методаStartAsync
. То есть, если внутри сервиса заэвейтит задачуawait
DoSomeWorkEveryFiveSecondsAsync(cancellationToken);
, то IHost будет бесконечно ждать старта сервиса и никогда не запустит остальные.Ну, или запустит, если внутри StartAsync завершится через какое-то время, например, прогрев кэши или завершив другое нужное нам перед началом обработки запросов действие.
Речь как раз о том, что если нам не нужно блокировать дальнейший запуск хоста, то внутри StartAsync достаточно запустить задачу без ожидания и следить за переданным
CancellationToken
. В этом случае await StartAsync получит Task.CompletedTask и продолжит дальнейшую инициализацию.slepmog
Как-то это всё очень странно.
И вы, и официальная документация рекомендуют fire and forget по типу #1, т.е. с игнорированием исключений. Причём если исключение выброшено из синхронной части вызываемого метода, оно будет передано наверх, а если микросекундой позже — проигнорировано.
Мне кажется, это применимо (?) только если из
StartAsync
вызывается именноExecuteAsync
, как в документации, а не что-то другое.Vadimyan Автор
Да, async/await работает именно так. Вижу здесь 2 возможных разреза вопроса. Но для начала хочу показать код запуска HosterService внутри класса Host:
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 нужно сконфигурировать хост для игнорирования таких исключений: