Background Services: IHostedService, BackgroundService?
Жизненный цикл
Важно: StartAsync должен быстро возвращаться — иначе приложение (и Kestrel) не стартует, пока он не завершится. BackgroundService решает это: ExecuteAsync выполняется в фоне.
IHostedService напрямую
public sealed class CacheWarmupService(IServiceScopeFactory scopes, CatalogCache cache) : IHostedService
{
public async Task StartAsync(CancellationToken ct)
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
cache.Load(await db.Categories.AsNoTracking().ToListAsync(ct)); // приложение стартует после загрузки
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}Для таких задач с .NET 8 есть IHostedLifecycleService (StartingAsync, StartedAsync, StoppingAsync, StoppedAsync) — более тонкие точки жизненного цикла.
BackgroundService: периодическая задача
public sealed class ExpiredReservationsCleaner(
IServiceScopeFactory scopes, TimeProvider clock, ILogger<ExpiredReservationsCleaner> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1), clock); // не накапливает «пропущенные» тики
do
{
try
{
await using var scope = scopes.CreateAsyncScope(); // новый скоуп на итерацию
var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
var removed = await db.Reservations
.Where(r => r.ExpiresAt < clock.GetUtcNow())
.ExecuteDeleteAsync(stoppingToken);
if (removed > 0) log.LogInformation("Удалено {Count} просроченных резервов", removed);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // штатная остановка
}
catch (Exception ex)
{
log.LogError(ex, "Ошибка очистки резервов"); // не роняем сервис из-за одной итерации
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
builder.Services.AddHostedService<ExpiredReservationsCleaner>();BackgroundService: потребитель очереди
public sealed class OrderEventsConsumer(IConnection rabbit, IServiceScopeFactory scopes, ILogger<OrderEventsConsumer> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await using var channel = await rabbit.CreateChannelAsync(cancellationToken: stoppingToken);
await channel.BasicQosAsync(0, 16, false, stoppingToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, ea) =>
{
await using var scope = scopes.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<IOrderEventHandler>();
try { await handler.HandleAsync(ea.Body, stoppingToken); await channel.BasicAckAsync(ea.DeliveryTag, false); }
catch (Exception ex) { log.LogError(ex, "Ошибка обработки"); await channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false); }
};
await channel.BasicConsumeAsync("order-events", autoAck: false, consumer, stoppingToken);
await Task.Delay(Timeout.Infinite, stoppingToken); // ждать до остановки
}
}(На практике для этого удобнее MassTransit — он сам является набором hosted services.)
Scoped-зависимости
Внедрить DbContext в конструктор hosted service нельзя (ошибка валидации скоупов) — и не нужно.
Несколько экземпляров приложения
Решения:
- Задача идемпотентна и безопасна при параллельном выполнении (очистка по условию — да; рассылка — нет).
- Распределённая блокировка / лидер:
pg_try_advisory_lock, Redis lock, библиотека DistributedLock, выбор лидера через Kubernetes Lease. - Отдельный воркер-сервис (Worker Service, 1 реплика или конкурирующие потребители очереди).
- Kubernetes CronJob для периодических задач.
- Hangfire / Quartz.NET с общим хранилищем — сами гарантируют однократный запуск задания в кластере.
Hangfire и Quartz.NET
| BackgroundService | Hangfire | Quartz.NET | |
|---|---|---|---|
| Хранение задач | Нет (в памяти) | БД (SQL Server, PostgreSQL, Redis) | БД (кластерный режим) или память |
| Переживает перезапуск | Нет | Да | Да (с хранилищем) |
| Ретраи, история, дашборд | Писать самому | Встроены, веб-дашборд | Ретраи — вручную, есть UI сторонние |
| Cron-расписание | Вручную | Да | Да, очень гибкое |
| Кластер без дублей | Вручную | Да | Да |
| Когда | Простые циклы, потребители, инициализация | Fire-and-forget, отложенные и повторяющиеся задания | Сложные расписания |
Вопросы на засыпку
Что будет, если ExecuteAsync выбросит исключение?
С .NET 6 и новее по умолчанию (BackgroundServiceExceptionBehavior.StopHost) исключение логируется и приложение останавливается. Альтернатива — Ignore (сервис молча умрёт, остальное продолжит работать — опасно). Поэтому цикл внутри ExecuteAsync должен ловить и логировать ошибки итераций.
Почему не Task.Run в контроллере для фоновой работы?
Запущенная так задача не отслеживается хостом: при остановке приложения она будет прервана, исключения потеряются, HttpContext и scoped-сервисы запроса к тому времени уже уничтожены. Правильно — положить задачу в очередь (Channel/брокер) и обработать в hosted service со своим скоупом.
Чем PeriodicTimer лучше Task.Delay в цикле?
PeriodicTimer поддерживает стабильный период (не «дрейфует» на время выполнения тела), не накапливает пропущенные тики, работает с TimeProvider (тестируемость) и корректно отменяется. Task.Delay в цикле добавляет время работы тела к интервалу.
Что такое Worker Service?
Шаблон dotnet new worker — приложение на Generic Host без веб-сервера, только hosted services. Разворачивается как отдельный процесс/контейнер, служба Windows (UseWindowsService) или демон systemd.
Итог
IHostedService встраивает фоновые компоненты в жизненный цикл приложения, BackgroundService упрощает долгоживущие циклы. Создавайте скоуп на каждую единицу работы, уважайте stoppingToken, ловите исключения итераций, используйте PeriodicTimer для периодики и помните о нескольких экземплярах приложения — для надёжных и распределённых заданий подключайте Hangfire/Quartz или брокер.