C# / .NET Фоновая обработка в .NET: Generic Host, BackgroundService, очереди и graceful shutdown
0%

Фоновая обработка в .NET: Generic Host, BackgroundService, очереди и graceful shutdown

Фоновая обработка и 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-хранилище — базу или брокер.

Брокеры и надёжная доставка

Ключевая проблема на этой схеме — двойная запись. Нельзя атомарно «сохранить заказ в базу и отправить событие в брокер»: это два разных хранилища. Упадёт между ними — получите либо заказ без события, либо событие без заказа.

Решение — 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: как не потерять работу при деплое

Деплой — это остановка процесса. Если сделать её грубо, вы потеряете обрабатываемые сообщения и оборвёте запросы на середине.

Что настроить, чтобы это работало:

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.

Типичные ошибки

  1. Scoped-зависимость в конструкторе BackgroundService — вечный DbContext.
  2. Никакого try/catch внутри цикла — одно плохое сообщение останавливает хост.
  3. Task.Delay вместо PeriodicTimer — дрейф расписания.
  4. System.Threading.Timer — перекрывающиеся запуски и гонки.
  5. Периодическая задача без блокировки на нескольких репликах — двойные письма и двойные списания.
  6. «Сохранить в БД и отправить в брокер» без outbox — рассогласование при сбое.
  7. Потребитель без идемпотентности — дубликаты при повторной доставке.
  8. Нет DLQ и лимита попыток — ядовитое сообщение крутится вечно.
  9. ShutdownTimeout больше grace period — SIGKILL посреди транзакции.
  10. Ноль метрик — воркер «умер» тихо, узнали от бизнеса через сутки.

Итог

  • 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: как ускорять сервис, опираясь на измерения, а не на догадки.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов