Фоновая обработка и Generic Host
Не всякая работа помещается в HTTP-запрос. Отправка письма, пересчёт отчёта, импорт выгрузки на сто тысяч строк, разбор сообщения из брокера, ночная чистка — всё это должно происходить вне цикла «запрос-ответ»: пользователь не будет ждать сорок секунд, а балансировщик разорвёт соединение раньше.
В .NET для такой работы есть единый механизм — Generic Host. Тот же самый хост, который запускает ваш веб-сервис, умеет запускать фоновые службы. Разберём, как он устроен, где на нём спотыкаются и как остановить сервис, не потеряв данные.
Generic Host: что на самом деле запускает приложение
WebApplication.CreateBuilder(...) строит IHost — контейнер, который владеет DI,
конфигурацией, логированием и коллекцией IHostedService. Веб-сервер Kestrel сам
зарегистрирован как один из hosted-сервисов. То есть «веб-приложение» — это частный случай
хоста с одной фоновой службой, которая слушает сокет.
// Чистый фоновый сервис без веба: dotnet new worker
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<OrderExportWorker>();
builder.Services.Configure<HostOptions>(o =>
{
o.ShutdownTimeout = TimeSpan.FromSeconds(30); // сколько ждём завершения при остановке
o.BackgroundServiceExceptionBehavior =
BackgroundServiceExceptionBehavior.StopHost; // поведение при необработанном исключении
});
await builder.Build().RunAsync();
Три интерфейса, которые стоит знать:
IHostedService— базовый контракт:StartAsyncиStopAsync. Используйте напрямую, когда нужна разовая инициализация (прогрев кэша, проверка миграций, подписка на брокер).BackgroundService— абстрактный класс поверх него с единственным методомExecuteAsync(CancellationToken). Это 95% случаев.IHostApplicationLifetime— доступ к событиямApplicationStarted/Stopping/Stoppedи к методуStopApplication(), которым сервис может остановить сам себя (например, при фатальной ошибке конфигурации).
BackgroundService и его ловушки
public sealed class OrderExportWorker(
IServiceScopeFactory scopeFactory, // ← ключевой момент, см. ниже
ILogger<OrderExportWorker> logger,
TimeProvider time) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Экспорт заказов запущен");
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Свой DI-скоуп на итерацию: DbContext — scoped, а воркер — singleton
await using var scope = scopeFactory.CreateAsyncScope();
var exporter = scope.ServiceProvider.GetRequiredService<IOrderExporter>();
await exporter.ExportPendingAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // штатная остановка, не ошибка
}
catch (Exception ex)
{
// Ошибка одной итерации не должна убивать воркер целиком
logger.LogError(ex, "Итерация экспорта упала, продолжаем");
}
await Task.Delay(TimeSpan.FromSeconds(30), time, stoppingToken);
}
logger.LogInformation("Экспорт заказов остановлен");
}
}
Четыре ловушки, каждая из которых стоила кому-то ночного инцидента:
1. Scoped-зависимости нельзя внедрять в конструктор. BackgroundService регистрируется
как singleton, а DbContext — scoped. Прямое внедрение даёт captive dependency: один
DbContext живёт вечно, копит отслеживаемые сущности и рано или поздно падает или течёт.
Решение — IServiceScopeFactory и свой скоуп на итерацию, как в примере.
2. Необработанное исключение из ExecuteAsync останавливает весь хост (поведение по
умолчанию с .NET 6). Это правильное поведение — «тихо мёртвый» воркер хуже упавшего пода, —
но о нём надо знать: оборачивайте тело цикла в try/catch, чтобы одна плохая запись не
убивала сервис.
3. Синхронная работа до первого await блокирует старт хоста. StartAsync ждёт, пока
ExecuteAsync дойдёт до первой асинхронной точки. Тяжёлая инициализация в начале метода
задержит запуск всего приложения, включая веб-сервер. Если нужен «мгновенный отпуск» —
начинайте с await Task.Yield().
4. stoppingToken отменяется при остановке. Токен нужно прокидывать во все вызовы, но
различать два случая: «прекратить брать новую работу» (сразу) и «доделать текущий элемент»
(с отдельным таймаутом). Слепое прокидывание одного токена везде приводит к оборванным
транзакциям.
Периодические задачи
Наивный Task.Delay в цикле дрейфует: реальный период равен задержке плюс времени
обработки. PeriodicTimer (.NET 6) держит равные интервалы и не запускает следующую
итерацию, пока не закончилась текущая:
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
{
await DoWorkAsync(ct); // если работа заняла 6 минут — следующий тик не «накопится»
}
}
Почему не System.Threading.Timer: он вызывает колбэк на потоке пула независимо от
того, закончился ли предыдущий вызов. Две одновременные итерации над одними данными — это
гонка, которую очень тяжело воспроизвести.
Для расписаний сложнее «раз в N минут» (по будням в 3:00, последнее число месяца) берут планировщик: Quartz.NET (гибкий cron, кластеризация, persistence) или Hangfire (очередь задач с дашбордом и ретраями «из коробки»).
Одна задача на несколько реплик
Как только сервис масштабируется до двух подов, периодическая задача начинает выполняться дважды. Варианты решения:
- Распределённая блокировка — Redis (
SET key value NX PX ttl) или строка-лидер в БД сSELECT ... FOR UPDATE SKIP LOCKED. Простой и рабочий вариант; см. Redis и координацию в распределённых системах. - Отдельный деплой воркера в одну реплику — скучно, надёжно, часто правильнее всего.
- Внешний планировщик — Kubernetes CronJob запускает job, который дёргает вашу задачу (см. Kubernetes).
Важно: блокировка не заменяет идемпотентность. Аренда лидера может истечь во время работы, и два экземпляра пересекутся. Задача должна быть безопасна при повторном выполнении — эта тема подробно разобрана в «Идемпотентности и доставке».
Очередь внутри процесса
Самый частый сценарий: HTTP-обработчик должен быстро ответить, а тяжёлую часть отдать в
фон. Идиоматичный инструмент — Channel<T> из
статьи про конкурентность плюс BackgroundService
в роли потребителя.
// Ограниченная очередь: back-pressure вместо съеденной памяти
public sealed class BackgroundTaskQueue(int capacity)
{
private readonly Channel<Func<CancellationToken, ValueTask>> _channel =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait });
public ValueTask EnqueueAsync(Func<CancellationToken, ValueTask> work, CancellationToken ct)
=> _channel.Writer.WriteAsync(work, ct);
public IAsyncEnumerable<Func<CancellationToken, ValueTask>> ReadAllAsync(CancellationToken ct)
=> _channel.Reader.ReadAllAsync(ct);
}
public sealed class QueuedHostedService(BackgroundTaskQueue queue, ILogger<QueuedHostedService> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var work in queue.ReadAllAsync(stoppingToken))
{
try { await work(stoppingToken); }
catch (Exception ex) { log.LogError(ex, "Фоновая задача упала"); }
}
}
}
Честное предупреждение: очередь в памяти теряется при перезапуске пода. Она годится для задач, потерю которых можно пережить (отправка метрики, прогрев кэша, необязательное уведомление). Всё, что нельзя терять, обязано попасть в durable-хранилище — базу или брокер.
Брокеры и надёжная доставка
в ОДНОЙ транзакции"| DB[("База данных
таблица outbox")] DB --> REL["Outbox-релей
BackgroundService"] REL -->|"публикация"| BR{{"Брокер
RabbitMQ / Kafka"}} BR --> CONS["Consumer
IHostedService"] CONS --> IDEM{"Уже обрабатывали
этот MessageId?"} IDEM -->|"да"| SKIP["Пропустить
идемпотентность"] IDEM -->|"нет"| WORK["Обработать
и записать факт"] WORK --> OK{"Успех?"} OK -->|"да"| ACK["Подтвердить (ack)"] OK -->|"нет, попытка меньше N"| RETRY["Отложенный повтор
с экспоненциальной задержкой"] RETRY --> BR OK -->|"нет, попыток больше N"| DLQ[("Dead-letter queue
разбирает человек")]
Ключевая проблема на этой схеме — двойная запись. Нельзя атомарно «сохранить заказ в базу и отправить событие в брокер»: это два разных хранилища. Упадёт между ними — получите либо заказ без события, либо событие без заказа.
Решение — transactional outbox: событие пишется в ту же базу и ту же транзакцию, что и бизнес-данные, а отдельный фоновый релей читает таблицу и публикует в брокер. Транзакция одна, а значит согласованность гарантирована базой (см. транзакции и изоляцию).
// Внутри одной транзакции EF Core: бизнес-данные и исходящее событие
public async Task<int> PlaceAsync(CreateOrderRequest req, CancellationToken ct)
{
var order = Order.Create(req);
db.Orders.Add(order);
db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(OrderPlaced),
Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id), JsonOptions),
OccurredAt = time.GetUtcNow()
});
await db.SaveChangesAsync(ct); // ← одна транзакция на всё
return order.Id;
}
Практические следствия, которые нужно закладывать сразу:
- Доставка «хотя бы один раз». Дубликаты неизбежны: релей мог опубликовать сообщение и
упасть до пометки. Потребитель обязан быть идемпотентным — обычно через таблицу
обработанных
MessageId. - Порядок не гарантирован (кроме как внутри партиции Kafka по ключу). Логика, зависящая от порядка, — источник плавающих багов.
- Ядовитые сообщения. Сообщение, которое всегда падает, без DLQ будет вечно перезапускаться, забивая очередь и логи. Ограничьте число попыток.
- Обратное давление. Ограничивайте параллелизм потребителя (
prefetch,MaxDegreeOfParallelism), иначе воркер выберет всю очередь и уронит базу.
В .NET поверх RabbitMQ/Kafka/Azure Service Bus обычно берут MassTransit — он приносит
consumer-абстракции, ретраи, DLQ, outbox и трейсинг; либо пишут потребителя вручную поверх
клиента брокера, оформляя его как IHostedService. Модели доставки и семантика брокеров —
в «Обмене сообщениями».
Graceful shutdown: как не потерять работу при деплое
Деплой — это остановка процесса. Если сделать её грубо, вы потеряете обрабатываемые сообщения и оборвёте запросы на середине.
маршрутизации разошлись по узлам K->>P: SIGTERM P->>H: запуск остановки H->>H: событие ApplicationStopping H->>W: отмена stoppingToken W->>W: перестать брать новые сообщения,
доделать текущее W-->>H: StopAsync завершён H->>H: дренаж активных HTTP-запросов H-->>P: ApplicationStopped P-->>K: процесс завершился (код 0) Note over K,P: если не уложились в
terminationGracePeriodSeconds — SIGKILL
Что настроить, чтобы это работало:
builder.Services.Configure<HostOptions>(o =>
{
o.ShutdownTimeout = TimeSpan.FromSeconds(25); // меньше, чем terminationGracePeriodSeconds
});
// Реакция на этапы жизненного цикла
public sealed class ConsumerService(IHostApplicationLifetime lifetime) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
lifetime.ApplicationStopping.Register(() => { /* закрыть подписку у брокера */ });
// Отдельный токен для «доделать текущее сообщение» — с собственным лимитом
while (!stoppingToken.IsCancellationRequested)
{
var message = await ReceiveAsync(stoppingToken);
using var finishing = new CancellationTokenSource(TimeSpan.FromSeconds(20));
await HandleAsync(message, finishing.Token); // НЕ stoppingToken
}
}
}
Правило соответствия таймаутов: ShutdownTimeout (25 с) меньше
terminationGracePeriodSeconds в Kubernetes (например, 40 с), а таймаут обработки одного
сообщения меньше ShutdownTimeout. Иначе SIGKILL прилетит посреди работы. Отдельно
проследите, что readiness-проба начинает отвечать отрицательно до SIGTERM — иначе
балансировщик продолжит слать трафик в останавливающийся под (см.
health checks в статье про деплой и
деградацию сервисов).
Наблюдаемость и тестирование фоновых задач
У фоновой работы нет пользователя, который пожалуется. Значит, наблюдаемость — не роскошь:
- Метрики очереди: длина, возраст самого старого сообщения (лаг), время обработки, число ошибок и попаданий в DLQ. Алерт вешают именно на лаг, а не на длину: очередь из тысячи быстро обрабатываемых сообщений безопасна, а из десяти застрявших — нет.
- Метрика «heartbeat»: счётчик успешных итераций. Отсутствие роста N минут — сигнал, что воркер жив, но ничего не делает. Это самый частый режим отказа фоновой обработки.
- Трейсинг: при публикации кладите
traceparentв заголовки сообщения, при обработке — восстанавливайте контекст и начинайте span черезActivitySource. Тогда путь «HTTP-запрос → outbox → брокер → воркер» виден одним трейсом (см. наблюдаемость).
Тестировать hosted-сервисы проще, чем кажется: это обычный класс, у которого можно вызвать
StartAsync/StopAsync руками. Время подменяется через TimeProvider (.NET 8) — пакет
Microsoft.Extensions.TimeProvider.Testing даёт FakeTimeProvider, который двигает часы
мгновенно, поэтому тест периодической задачи выполняется за миллисекунды, а не за пять
минут.
[Fact]
public async Task Worker_ObrabatyvaetOchered()
{
var time = new FakeTimeProvider();
var queue = new BackgroundTaskQueue(capacity: 10);
var sut = new QueuedHostedService(queue, NullLogger<QueuedHostedService>.Instance);
await sut.StartAsync(CancellationToken.None);
var done = false;
await queue.EnqueueAsync(_ => { done = true; return ValueTask.CompletedTask; },
CancellationToken.None);
time.Advance(TimeSpan.FromSeconds(1)); // двигаем время вместо реального ожидания
await sut.StopAsync(CancellationToken.None);
done.Should().BeTrue();
}
Когда фон — это не про фон
Отдельно проговорим границу. Фоновая служба внутри веб-приложения конкурирует с обработкой запросов за те же потоки, память и лимиты пода. Если задача тяжёлая или её объём меняется независимо от трафика — выносите её в отдельный worker-деплой. Тогда вы масштабируете API и обработку раздельно, а всплеск импорта не роняет латентность API.
Признаки, что пора выносить: фоновая работа занимает больше 20–30% CPU пода, требует принципиально другого масштабирования, имеет иной профиль памяти (большие буферы), или её падение не должно влиять на доступность API.
Типичные ошибки
- Scoped-зависимость в конструкторе
BackgroundService— вечныйDbContext. - Никакого try/catch внутри цикла — одно плохое сообщение останавливает хост.
Task.DelayвместоPeriodicTimer— дрейф расписания.System.Threading.Timer— перекрывающиеся запуски и гонки.- Периодическая задача без блокировки на нескольких репликах — двойные письма и двойные списания.
- «Сохранить в БД и отправить в брокер» без outbox — рассогласование при сбое.
- Потребитель без идемпотентности — дубликаты при повторной доставке.
- Нет DLQ и лимита попыток — ядовитое сообщение крутится вечно.
ShutdownTimeoutбольше grace period — SIGKILL посреди транзакции.- Ноль метрик — воркер «умер» тихо, узнали от бизнеса через сутки.
Итог
- Generic Host — общий каркас: и веб-сервер, и воркеры живут в нём как
IHostedService. BackgroundServiceтребует собственного DI-скоупа на итерацию и обязательного try/catch.PeriodicTimer— правильный способ делать «раз в N»; для расписаний — Quartz/Hangfire.- Очередь в памяти теряется при перезапуске; всё важное — в durable-хранилище.
- Outbox решает проблему двойной записи; потребитель обязан быть идемпотентным.
- Корректная остановка — это согласованные таймауты хоста, обработки и оркестратора.
- Фоновая работа наблюдается по лагу и heartbeat, а не по «вроде работает».
Источники: документация по фоновым задачам, Generic Host, документация MassTransit, описание паттерна Transactional Outbox у Microsoft.
Что дальше
Производительность .NET — аллокации и Span<T>,
корректные бенчмарки, генераторы кода вместо рефлексии, настройка GC и JIT: как ускорять
сервис, опираясь на измерения, а не на догадки.