RE:NODE

Приложения12 мин чтения

Worker services и фоновые задачи в .NET: от hosted service до Hangfire

Как правильно запускать фоновую работу в .NET: BackgroundService, PeriodicTimer, очереди в процессе на Channels, Quartz.NET, Hangfire, обработка ошибок и чистое завершение.

0 прочтений

Фоновая работа в .NET начинается с BackgroundService: класса с одним методом, ExecuteAsync, который Generic Host запускает вместе с приложением и отменяет, когда приложение останавливается. Для «каждые пять минут делай вот это» достаточно BackgroundService с PeriodicTimer. Для «сделай это вскоре, не заставляя HTTP-запрос ждать» достаточно ограниченного Channel, который питает BackgroundService. За библиотекой стоит тянуться, когда работа должна переживать перезапуск или следовать календарю: Quartz.NET - для расписаний в стиле cron, Hangfire - для задач, сохраняемых в базе данных, с повторами и дашбордом. Что бы вы ни использовали, работает ли это в продакшене, решают одни и те же две вещи: что происходит, когда задача выбрасывает исключение, и что происходит, когда процессу велят остановиться посреди задачи.

В этой статье каждый вариант собирается по порядку - от наименее к наиболее требовательному к инфраструктуре.

Модель хостинга#

Каждое современное .NET-приложение - веб, worker или консольное с хостом - работает на Generic Host, а хост запускает hosted services. IHostedService - это интерфейс: StartAsync, когда приложение запускается, StopAsync, когда останавливается. BackgroundService - базовый класс, которым почти все пользуются вместо него, потому что он превращает это в один долгоживущий метод с токеном отмены.

bash
$ dotnet new worker -n Reports.Worker
Program.cs
var builder = Host.CreateApplicationBuilder(args);builder.Services.AddHostedService<CleanupWorker>();builder.Build().Run();

В приложении ASP.NET Core это та же строка, builder.Services.AddHostedService<CleanupWorker>(), и worker работает внутри веб-процесса рядом с конвейером запросов. Это не компромисс. Для большинства небольших приложений это правильное место, потому что worker делит с веб-приложением конфигурацию, логирование и сервисы, а деплоить нужно только один процесс.

Hosted services запускаются в порядке регистрации, и в старых версиях запуск был последовательным: код в ExecuteAsync до первого await выполнялся на пути запуска и задерживал всё, что зарегистрировано после него. В .NET 10 BackgroundService изменили так, что весь ExecuteAsync выполняется в фоне. В более ранних версиях держите синхронную часть короткой или начинайте метод с await Task.Yield(). В .NET 8 также появились HostOptions.ServicesStartConcurrently и IHostedLifecycleService с хуками StartingAsync, StartedAsync, StoppingAsync и StoppedAsync для сервисов, которым нужен более тонкий контроль.

Worker, работающий по таймеру#

Классическая форма - цикл с PeriodicTimer:

CleanupWorker.cs
public sealed class CleanupWorker(    IServiceScopeFactory scopes,    ILogger<CleanupWorker> log) : BackgroundService{    protected override async Task ExecuteAsync(CancellationToken stoppingToken)    {        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));        do        {            try            {                await using var scope = scopes.CreateAsyncScope();                var db = scope.ServiceProvider.GetRequiredService<ShopDb>();                var removed = await db.Carts                    .Where(c => c.UpdatedAt < DateTime.UtcNow.AddDays(-7))                    .ExecuteDeleteAsync(stoppingToken);                log.LogInformation("Removed {Count} stale carts", removed);            }            catch (Exception ex) when (ex is not OperationCanceledException)            {                log.LogError(ex, "Cart cleanup failed");            }        }        while (await timer.WaitForNextTickAsync(stoppingToken));    }}

Детали, которые имеют значение:

  • Scope. Hosted service - это singleton. Scoped-сервисы вроде DbContext из EF Core нельзя внедрить в него напрямую - хост откажется запускаться, - да и держать их всё время его жизни не стоит, потому что трекер изменений растёт бесконечно. Создавайте scope на каждую единицу работы через IServiceScopeFactory, как выше.
  • `PeriodicTimer` не допускает наложений. Если один прогон длится дольше периода, следующий тик ждёт; прогоны никогда не накладываются друг на друга. Обычно это то, что нужно, и это не так для System.Threading.Timer, который срабатывает по расписанию, закончился предыдущий обратный вызов или нет.
  • `WaitForNextTickAsync` выбрасывает исключение при отмене. Когда хост останавливается, токен отменяется, и ожидание выбрасывает OperationCanceledException, что завершает цикл. BackgroundService считает это нормальной остановкой.
  • Перехватывайте исключения на каждой итерации. try внутри цикла означает, что один неудачный прогон записывается в лог, а следующий всё равно состоится. Без него первое же исключение завершает worker - см. ниже.

Это покрывает на удивление много: прогрев кэша, очистку, опрос внешнего API, отправку писем из очереди, пересчёт таблицы лидеров. Какие из этих задач вообще место в приложении, обсуждается в статье фоновые задачи на небольшом сервере.

Очереди внутри процесса на Channels#

Для работы, которую запускает запрос, - отправить приветственное письмо, изменить размер загруженного файла, вызвать медленный вебхук, - запрос должен возвращаться сразу, а работа выполняться за ним. System.Threading.Channels - встроенная очередь с малым числом выделений для этого:

csharp
public sealed class EmailQueue{    private readonly Channel<int> _channel = Channel.CreateBounded<int>(        new BoundedChannelOptions(500) { FullMode = BoundedChannelFullMode.Wait });    public ValueTask EnqueueAsync(int userId, CancellationToken ct) =>        _channel.Writer.WriteAsync(userId, ct);    public IAsyncEnumerable<int> ReadAllAsync(CancellationToken ct) =>        _channel.Reader.ReadAllAsync(ct);}public sealed class EmailWorker(EmailQueue queue, IServiceScopeFactory scopes,    ILogger<EmailWorker> log) : BackgroundService{    protected override async Task ExecuteAsync(CancellationToken stoppingToken)    {        await foreach (var userId in queue.ReadAllAsync(stoppingToken))        {            try            {                await using var scope = scopes.CreateAsyncScope();                var sender = scope.ServiceProvider.GetRequiredService<IWelcomeMailer>();                await sender.SendAsync(userId, stoppingToken);            }            catch (Exception ex) when (ex is not OperationCanceledException)            {                log.LogError(ex, "Welcome mail for {UserId} failed", userId);            }        }    }}

Зарегистрируйте EmailQueue как singleton, а worker - как hosted service; эндпоинт вызывает EnqueueAsync и возвращает ответ.

Делайте канал ограниченным. Неограниченный канал принимает работу быстрее, чем вы успеваете её обрабатывать, пока не кончится память; ограниченный с FullMode = Wait вместо этого замедляет производителей, и это честное поведение. Ставьте в очередь маленькие вещи - ID, а не сущность или файл, - чтобы память очереди оставалась предсказуемой.

Чётко понимайте слабое место: очередь в памяти умирает вместе с процессом. Всё, что ждёт в ней, когда приложение перезапускается или падает, пропадает. Для приветственного письма это часто приемлемо; для подтверждения платежа - нет. Эта граница - «могу ли я это потерять?» - и есть момент, когда вам нужна персистентность, а значит, Hangfire, таблица в базе данных, которую вы опрашиваете, или сервер очередей. Вариант с сервером очередей описан в статье очереди задач на Valkey.

Ошибки и что убивает хост#

Начиная с .NET 6 исключение, вырвавшееся из ExecuteAsync, останавливает весь хост. За это отвечает настройка HostOptions.BackgroundServiceExceptionBehavior, и её значение по умолчанию - StopHost:

csharp
builder.Services.Configure<HostOptions>(o =>{    o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;});

Это значение по умолчанию правильное, а альтернатива, Ignore, обычно неправильная. С Ignore worker молча останавливается, пока приложение продолжает обслуживать запросы, и ничто не сообщит вам, что очистка перестала выполняться три недели назад. С StopHost процесс завершается, исключение записывается в лог, и платформа перезапускает процесс - это заметно и самоизлечимо, если сбой был временным.

Настоящее исправление - try/catch на каждой итерации, показанный выше: одна плохая запись должна стоить одной итерации, а не процесса. Оставьте завершение процесса для действительно фатальных сбоев, например отсутствующей конфигурации.

У перехвата всего подряд есть свой режим отказа: worker, который падает на каждой итерации, каждый раз пишет ошибку в лог и никогда не останавливается. Лог никто не читает, и задача фактически мертва уже несколько недель. Записывайте время последнего успешного прогона в singleton и выставляйте его через проверку состояния, которая сообщает о нездоровье, когда последний успех старше, скажем, трёх периодов. Тогда проверка мониторинга - или просто эндпоинт проверки состояния, который вы и так вызываете после деплоев, - сообщит вам, что задача застряла, раньше, чем это сделает клиент. О чём стоит слать оповещения, а что оставить в покое, разобрано в статье мониторинг, который что-то сообщает.

В RE:NODE завершившийся процесс перезапускается, а наблюдатель за сбоями считает перезапуски, о которых никто не просил: три за час выводят предупреждение на странице сервера и автоматически открывают тикет, а шесть приводят к приостановке. Worker, который выбрасывает исключение при каждом запуске, поэтому быстро замечают, - но лучше перехватывать ошибки по каждому элементу и писать их в лог, чем полагаться на это.

Завершение: что происходит при остановке и деплое#

Когда хосту велят остановиться - деплой, перезапуск, остановка из панели, - он отменяет stoppingToken и ждёт, пока каждый hosted service завершится, но не дольше HostOptions.ShutdownTimeout. Значение по умолчанию начиная с .NET 6 - 30 секунд. После этого хост перестаёт ждать, и процесс завершается, при необходимости посреди задачи.

Поэтому каждая задача должна выдерживать прерывание:

  1. Передавайте токен везде. Вызовы базы данных, HTTP-вызовы и задержки, получившие stoppingToken, быстро завершаются, когда он отменён. Цикл, который его игнорирует, работает до таймаута, а затем всё равно обрывается.
  2. Делайте работу идемпотентной. Задача, прерванная на полпути, выполнится снова. Отправить одно и то же письмо дважды или списать деньги дважды должно быть невозможно по замыслу: записывайте, что сделано, и проверяйте, прежде чем делать.
  3. Держите единицы работы небольшими. Задача, которая обрабатывает 10 000 строк в одной транзакции, при прерывании теряет всё. Пакеты по несколько сотен, каждый зафиксированный, теряют не больше одного пакета.

Если вы запускаете процесс сами, стартовая команда должна передавать сигнал остановки прямо процессу .NET - exec dotnet App.dll, а не обёртка в оболочке, - иначе хост его никогда не услышит и не сможет завершиться чисто. Сторона сигналов в целом описана в статье graceful shutdown и проверки состояния.

Quartz.NET для расписаний#

Когда работа должна происходить в определённое время - в 03:00 каждую ночь, в первый понедельник месяца, - а не через интервал, используйте планировщик. Quartz.NET - устоявшийся вариант:

bash
$ dotnet add package Quartz.Extensions.Hosting
csharp
builder.Services.AddQuartz(q =>{    var key = new JobKey("nightly-report");    q.AddJob<NightlyReportJob>(o => o.WithIdentity(key));    q.AddTrigger(t => t.ForJob(key).WithCronSchedule("0 0 3 * * ?"));});builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);[DisallowConcurrentExecution]public sealed class NightlyReportJob(ShopDb db) : IJob{    public async Task Execute(IJobExecutionContext context)    {        // build and send the report, honouring context.CancellationToken    }}

Обратите внимание на cron-выражение. В cron Quartz первым идёт поле секунд, а в одном из полей дня обязателен ?, так что 0 0 3 * * ? означает «03:00:00 каждый день» - это не тот пятипольный синтаксис Unix, который знает большинство. Форма Unix разобрана в статье cron-выражения простыми словами; прежде чем доверять расписанию, прочитайте собственную документацию Quartz о его варианте.

[DisallowConcurrentExecution] не даёт медленному прогону наложиться на следующий. WaitForJobsToComplete заставляет завершение ждать выполняющиеся задачи в пределах таймаута завершения хоста. Расписания вычисляются в часовом поясе сервера, если не задать его на триггере через InTimeZone, а это важно дважды в год, когда переводят часы.

По умолчанию Quartz хранит расписание в памяти, так что прогон, который должен был случиться, пока приложение было выключено, просто пропускается. Если пропущенные прогоны важны, Quartz поддерживает хранилище задач в базе данных с обработкой пропусков (misfire); на этом этапе сравните его с Hangfire.

Hangfire для персистентных задач#

Hangfire хранит задачи в базе данных, так что они переживают перезапуски, автоматически повторяются при сбое, и их можно просматривать и перезапускать из веб-дашборда:

csharp
builder.Services.AddHangfire(c => c.UseSqlServerStorage(    builder.Configuration.GetConnectionString("Hangfire")));builder.Services.AddHangfireServer();var app = builder.Build();app.UseHangfireDashboard("/jobs");// enqueue from anywhere, via IBackgroundJobClientjobs.Enqueue<IWelcomeMailer>(m => m.SendAsync(userId, CancellationToken.None));// recurring, via IRecurringJobManagerrecurring.AddOrUpdate<NightlyReport>("nightly-report", r => r.RunAsync(), Cron.Daily());

Как это работает: Enqueue сериализует вызов метода и его аргументы в хранилище, а серверный компонент опрашивает хранилище и выполняет их. Следствия:

  • Аргументы должны быть небольшими и сериализуемыми. Передавайте ID, а не сущность; задача загружает свежие данные, когда выполняется.
  • Неудачные задачи повторяются автоматически, по умолчанию десять раз с растущими задержками. Вот почему задачи должны быть идемпотентными.
  • Хранилище - настоящая зависимость. Хранилище на SQL Server поддерживают авторы Hangfire; хранилища на PostgreSQL и MySQL - из пакетов сообщества; хранилище на Redis входит в коммерческую редакцию Pro. Проверьте пакет для своей базы данных, прежде чем выбирать.
  • Дашборд по умолчанию доступен только локально. Запросы откуда угодно, кроме localhost, отклоняются, пока вы не добавите фильтр авторизации. Не «исправляйте» это, разрешив доступ всем; дашборд умеет удалять и перезапускать задачи.

Ядро Hangfire - открытый код под LGPL; пакеты задач (batches) и некоторые другие возможности - в платной редакции Pro.

Выбор и где выполняется работа#

ПотребностьЧто использовать
Каждые N минут, потеря прогона при перезапуске допустимаBackgroundService + PeriodicTimer
Запустить и забыть из запроса, потеря допустимаChannel + BackgroundService
В заданное время, по календарюQuartz.NET
Должно переживать перезапуски, повторы, видимостьHangfire или очередь на Valkey

Где выполняется работа - второе решение. Один сервер запускает один процесс, так что на одном тарифе для приложений фоновая работа живёт внутри веб-приложения как hosted services - и для большинства приложений это нормально. Выносите её в отдельный процесс worker на собственном сервере, когда фоновая работа начинает конкурировать с запросами за CPU или память: в RE:NODE CPU жёстко ограничен купленной долей, так что тяжёлая ночная задача внутри веб-процесса замедляет сайт, пока выполняется. Hangfire и очереди на базе данных упрощают такое разделение в будущем, потому что веб-приложение и worker делят только базу данных. Тарифы для приложений включают два слота баз данных, а этого хватает для данных приложения и хранилища задач.

Разобранный пример: небольшой магазин на одном тарифе для приложений. Брошенные корзины раз в час очищает BackgroundService с PeriodicTimer - потеря одного прогона из-за перезапуска ничего не стоит. Письма с подтверждением заказа идут через Hangfire с хранилищем на SQL Server или PostgreSQL, потому что потерянное подтверждение - это тикет в поддержку, а Hangfire повторит отправку, если почтовый релей ненадолго недоступен. Ночной отчёт о продажах - повторяющаяся задача Hangfire, а не отдельная настройка Quartz, потому что Hangfire уже есть, а об одном планировщике рассуждать проще, чем о двух. Всё работает в веб-процессе. Если отчёт когда-нибудь станет настолько тяжёлым, что начнёт замедлять сайт в 03:00, сервер Hangfire без изменений переезжает на второй тариф, а веб-приложение просто перестаёт вызывать AddHangfireServer().

Собственная вкладка Schedules в панели - другой инструмент: она выполняет упорядоченные задачи по cron-выражению - консольную команду, backup или действие с питанием. Это правильное место для ночного перезапуска или backup перед рискованной задачей, а не для логики приложения. Полезные варианты перечислены в статье плановые задачи, которые стоит завести.

FAQ#

Фоновые задачи должны выполняться в веб-приложении или в отдельном процессе?

В веб-приложении, пока они не конкурируют с ним за CPU или память. Один процесс проще деплоить и мониторить. Разделяйте, когда нагрузка задачи заметно замедляет запросы, и используйте персистентные задачи, чтобы любой процесс можно было перезапускать независимо.

Почему мой BackgroundService перестаёт работать без всякой ошибки?

Либо он один раз выбросил исключение при BackgroundServiceExceptionBehavior, равном Ignore, либо цикл завершился штатно - часто из-за условия while, ставшего ложным. Пишите в лог в начале и в конце ExecuteAsync, чтобы видеть, когда он останавливается.

Можно ли внедрить DbContext в BackgroundService?

Не напрямую: он scoped, а сервис - singleton. Внедрите IServiceScopeFactory и создавайте scope на каждую единицу работы.

Как выполнить задачу ровно один раз на нескольких экземплярах?

Таймер внутри процесса срабатывает по разу на каждом экземпляре. Используйте Hangfire или Quartz с общим хранилищем в базе данных - оба координируют работу между серверами - или берите блокировку в базе данных перед выполнением.

Task.Run в контроллере - это фоновая задача?

Нет. Он выполняется в пуле потоков без обработки завершения, без сообщений об ошибках и без scope, и теряется при перезапуске. Вместо этого кладите работу в очередь, которую обрабатывает hosted service. Почему работа, захватывающая объекты запроса, к тому же держит память дольше, чем вы ожидаете, объясняет статья память и сборка мусора в .NET.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000