BackgroundService vs IHostedService vs Hangfire для фоновых задач в .NET 11
Выбирайте BackgroundService для внутрипроцессных циклов, чистый IHostedService для тонкого контроля жизненного цикла и Hangfire, когда задачи должны переживать перезапуск. Матрица принятия решений с кодом и одна деталь, которая решает за вас.
Для фоновой работы в приложении .NET 11 короткий ответ такой: используйте BackgroundService для непрерывных внутрипроцессных циклов и потребителей очередей, спускайтесь до чистого IHostedService только тогда, когда нужен явный упорядоченный запуск или остановка, и обращайтесь к Hangfire в тот момент, когда задача должна пережить перезапуск процесса или её нужно запланировать на “следующий вторник в 2 часа ночи”. Первые два варианта - это одна и та же примитива хостинга на разной высоте, и они не стоят вам ничего дополнительно. Hangfire - это отдельная зависимость с базой данных позади, и именно за эту базу данных вы платите. Этот пост строит матрицу принятия решений, показывает минимальный код для каждого варианта и указывает на единственное требование (долговечность), которое обычно решает за вас.
Все примеры нацелены на .NET 11 и C# 14. Примеры Hangfire используют Hangfire 1.8.x (Hangfire.AspNetCore плюс Hangfire.SqlServer).
Матрица возможностей
Это та таблица, ради которой вы пришли. Сначала прочитайте строку “Переживает перезапуск”; именно она разделяет поле.
| Возможность | IHostedService | BackgroundService | Hangfire |
|---|---|---|---|
| Встроено в .NET 11 | да | да | нет (NuGet + хранилище) |
| Дополнительная инфраструктура | нет | нет | SQL Server / Redis / Postgres |
| Поверхность жизненного цикла | StartAsync/StopAsync | один ExecuteAsync | нет (вы ставите задачи в очередь) |
| Лучше всего для | шаги старта/остановки | долгоживущие циклы | разовые и запланированные задачи |
| Переживает перезапуск | нет | нет | да |
| Повторы при сбое | вы пишете сами | вы пишете сами | автоматически, настраиваемо |
| Планирование (cron, задержка) | вы пишете сами | вы пишете сами | встроено |
| Работа на нескольких экземплярах | работает на каждом | работает на каждом | один worker берёт каждую задачу |
| Панель / видимость | нет | нет | встроенная веб-панель |
| Стоимость | бесплатно | бесплатно | ядро OSS; иногда лицензия Pro |
BackgroundService - это не альтернатива IHostedService; это абстрактный класс, который его реализует. Поэтому реальный выбор двусторонний: внутрипроцессный сервис хостинга (в одной из двух форм) против внешней долговечной системы задач. Разберём по порядку.
IHostedService: чистый контракт жизненного цикла
IHostedService - это низкоуровневый интерфейс, который универсальный хост .NET вызывает во время запуска и остановки. У него ровно два метода:
// .NET 11, C# 14
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
Хост ожидает (await) StartAsync каждого зарегистрированного сервиса в порядке регистрации, прежде чем обслужить первый запрос, и ожидает StopAsync (до HostOptions.ShutdownTimeout, по умолчанию 30 секунд), прежде чем процесс завершится. Эта гарантия порядка - причина использовать чистый интерфейс: это правильное место для работы, которая должна завершиться до прихода трафика (прогрев кеша, разовая проверка миграций, открытие долгоживущего соединения).
// .NET 11, C# 14
public sealed class CacheWarmer(IMemoryCache cache, IProductRepository repo) : IHostedService
{
public async Task StartAsync(CancellationToken ct)
{
// Runs to completion BEFORE the app starts serving requests.
var hot = await repo.GetHotProductsAsync(ct);
cache.Set("hot-products", hot);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
Ловушка с чистым IHostedService - выполнять долгоживущую работу внутри StartAsync. Если вы запустите там бесконечный цикл и дождётесь его через await, хост никогда не завершит запуск. Вам нужно запустить цикл без ожидания и отслеживать Task самостоятельно, чтобы затем отменить его и дождаться в StopAsync. Именно эту бухгалтерию BackgroundService существует, чтобы устранить.
Если вам нужен ещё более тонкий контроль (хук, который выполняется после того, как каждый сервис хостинга запустился, или прямо перед началом остановки), .NET 8 добавил IHostedLifecycleService, который расширяет IHostedService методами StartingAsync/StartedAsync и StoppingAsync/StoppedAsync. Он по-прежнему актуален в .NET 11 и является документированным местом для межсервисной проверки “теперь всё поднято”, как описывает разбор интерфейса от Steve Gordon.
BackgroundService: цикл, который вам на самом деле нужен
BackgroundService - это абстрактный базовый класс, который реализует IHostedService за вас с помощью паттерна “шаблонный метод”. Вы переопределяете один метод:
// .NET 11, C# 14
public sealed class QueuePump(IServiceScopeFactory scopeFactory, ILogger<QueuePump> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
await processor.DrainOnceAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // normal shutdown
}
catch (Exception ex)
{
logger.LogError(ex, "Order pump iteration failed; retrying");
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
Фреймворк вызывает ExecuteAsync из своего собственного StartAsync, сигнализирует stoppingToken, когда хост останавливается, и ожидает (await) возвращённый вами Task во время остановки. Две детали кусают разработчиков достаточно часто, чтобы их выделить:
BackgroundService- это синглтон. Вы не можете внедрить scoped-сервис вродеDbContextнапрямую; вы берётеIServiceScopeFactoryи открываете один scope на единицу работы, ровно как выше. Я написал отдельный разбор о использовании scoped-сервисов внутри BackgroundService.- Необработанное исключение в
ExecuteAsyncостанавливает сервис тихо (а с .NET 6 по умолчанию останавливает весь хост черезBackgroundServiceExceptionBehavior.StopHost). Оберните тело цикла в try/catch, если одна плохая итерация не должна убивать сервис, как показано.
Регистрируйте любую из форм одинаково:
// .NET 11, C# 14 -- Program.cs
builder.Services.AddHostedService<QueuePump>(); // BackgroundService
builder.Services.AddHostedService<CacheWarmer>(); // raw IHostedService
BackgroundService в паре с ограниченным System.Threading.Channel - это каноническая внутрипроцессная очередь задач: производители пишут рабочие элементы, сервис их вычитывает. Если вы когда-либо тянулись к Task.Run из контроллера, то это и есть паттерн, который вам действительно был нужен: см. безопасный запуск работы fire-and-forget с помощью BackgroundService и более широкий аргумент в пользу Channels вместо BlockingCollection.
Когда выбирать внутрипроцессные варианты
Выбирайте BackgroundService, когда:
- У вас непрерывный цикл: потребитель очереди, поллер, heartbeat, сброс метрик. Это его родная стихия.
- Допустимо потерять работу при остановке, или вы вычитываете находящиеся в обработке элементы в коротком окне
StopAsync. Повторы отправки писем, которые всё равно будут перезапущены из очереди, обновления кеша, отправка логов. - Вы хотите ноль новой инфраструктуры. Он поставляется в
Microsoft.Extensions.Hosting; устанавливать или провижинить нечего.
Выбирайте чистый IHostedService (или IHostedLifecycleService), когда:
- Вам нужно, чтобы работа завершилась до обслуживания первого запроса (прогрев кеша, проверка схемы, предзагрузка feature flag).
- Вам нужен упорядоченный запуск или остановка нескольких сервисов, либо хук проверки “всё зелёное” после запуска.
- Работа - это дискретный шаг старта/остановки, а не бесконечный цикл, поэтому форма с единственным
ExecuteAsyncуBackgroundServiceне подходит.
Оба работают на каждом экземпляре вашего приложения. Если вы масштабируетесь до трёх реплик, ваш BackgroundService работает трижды, параллельно, без координации. Для поллера без состояния это нормально. Для “отправить ночное письмо со счетами один раз” это баг.
Когда выбирать Hangfire
Выбирайте Hangfire, когда верно любое из этого:
- Задача должна пережить перезапуск или падение. Hangfire записывает задачу в хранилище (SQL Server, Redis или PostgreSQL) перед выполнением, поэтому деплой в середине задачи не теряет её. Задача подхватывается снова. Это ключевая возможность.
- Вам нужно планирование. “Запустить через 10 минут”, “каждый будний день в 6 утра” (cron), “в этот точный момент UTC”. Встроено, без математики таймеров.
- Вам нужны автоматические повторы с откатом. Hangfire по умолчанию повторяет неудавшиеся задачи настраиваемое число раз, с историей попыток, видимой в его панели.
- Вам нужно единственное выполнение на N экземплярах. Серверы Hangfire конкурируют за задачи из общего хранилища, поэтому каждая задача выполняется один раз независимо от того, сколько экземпляров приложения подняты. Это чисто решает проблему “ночное письмо три раза”.
- Вам нужна операционная видимость. Встроенная панель показывает задачи в очереди, в обработке, успешные и неудавшиеся со трассировками стека - то, что иначе пришлось бы строить самому.
Минимальная настройка в .NET 11:
// .NET 11, C# 14 -- Program.cs
builder.Services.AddHangfire(cfg => cfg
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb")));
builder.Services.AddHangfireServer();
var app = builder.Build();
app.UseHangfireDashboard("/jobs"); // lock this down in production
// Fire-and-forget, durable:
BackgroundJob.Enqueue<IInvoiceService>(s => s.SendAsync(orderId, CancellationToken.None));
// Recurring (cron):
RecurringJob.AddOrUpdate<IReportService>(
"nightly-report",
s => s.BuildAsync(CancellationToken.None),
Cron.Daily(2));
Заметьте, что только что изменилось: теперь вы владеете набором таблиц базы данных, которым управляет Hangfire, строкой подключения, миграциями этой схемы между обновлениями Hangfire и эндпоинтом панели, который нужно авторизовать. Это реальный операционный вес. Вы берёте его на себя осознанно, в обмен на долговечность и планирование, которые иначе плохо собрали бы вручную.
Картина пропускной способности, с реальными числами
Производительность здесь редко является решающей осью, но стоит быть честным насчёт стоимости долговечности. Внутрипроцессный BackgroundService, вычитывающий Channel, не делает I/O на элемент сверх вашей собственной работы; накладные расходы на диспетчеризацию - это фактически вызов метода, и они не измеримы на фоне самой работы. Hangfire, напротив, делает как минимум один обход к хранилищу для извлечения из очереди и один для отметки завершения на каждую задачу.
Собственная документация Hangfire количественно оценивает выбор хранилища: переход с SQL Server на Redis даёт более чем 4-кратную пропускную способность на пустых задачах, согласно руководству по Redis. Абсолютные числа зависят от задержки вашего хранилища, но форма фиксирована: нижняя граница Hangfire - “обходы к базе данных”, а нижняя граница внутрипроцессной очереди - “ничего”. Если вы обрабатываете десятки тысяч тривиальных элементов в секунду, этот разрыв важен, и внутрипроцессная очередь на Channel выигрывает безоговорочно. Если вы обрабатываете тысячи задач в минуту, каждая из которых делает реальную работу (вызов API, рендеринг PDF), стоимость хранилища на задачу растворяется в шуме, и долговечность на практике бесплатна.
Правило, которое из этого следует: не пропускайте высокочастотную, толерантную к потерям работу через Hangfire только потому, что он под рукой. Поллер, проверяющий очередь каждую секунду, - это BackgroundService, а не 86 400 задач Hangfire в день.
Деталь, которая решает за вас
Два требования заканчивают спор до того, как в дело вступают предпочтения:
-
“Это не должно теряться, если приложение перезапустится.” Если задача отбрасывается при деплое и это реальный баг (списание платежа, письмо-подтверждение, доставка вебхука), вам нужно долговечное хранилище, а это означает Hangfire (или настоящий брокер сообщений). Никакое вычитывание в
StopAsyncне заставитBackgroundServiceпережитьkill -9или отказ узла. Внутрипроцессные варианты держат работу в памяти; память умирает вместе с процессом. -
“Это должно выполниться ровно один раз на моих репликах.”
BackgroundServiceработает на каждом экземпляре. Если вы масштабируетесь горизонтально, а задача не идемпотентна, вы получаете дублирующуюся работу. Модель worker’ов Hangfire с общим хранилищем даёт единственное выполнение бесплатно. Внутрипроцессный эквивалент - распределённая блокировка, которую нужно построить и сделать правильно.
Если ни одно из двух требований не применимо (работа внутрипроцессная, толерантная к потерям и либо выполняется один раз, потому что вы запускаете один экземпляр, либо естественно идемпотентна), то добавление Hangfire - это уплата налога базе данных ни за что. Используйте BackgroundService.
Распространённый и правильный гибрид: держите долговечные расписание и повторы в Hangfire, но пусть тело повторяющейся задачи просто ставит в очередь во внутрипроцессный Channel, который вычитывает BackgroundService. Hangfire гарантирует, что задача сработает один раз и переживёт перезапуски; Channel даёт быструю, учитывающую обратное давление внутрипроцессную пропускную способность. Вы получаете оба свойства, не прогоняя каждый элемент через хранилище.
Рекомендация, повторно
По умолчанию используйте BackgroundService для всего, что зацикливается внутри процесса. Обращайтесь к чистому IHostedService или IHostedLifecycleService только тогда, когда вам конкретно нужен порядок запуска или хуки до/после остановки. Принимайте Hangfire в тот момент, когда задача должна пережить перезапуск, выполняться по расписанию, автоматически повторяться или выполняться ровно один раз на нескольких экземплярах, и примите базу данных, которую он приносит, как цену этих гарантий. Инстинкт хвататься за Hangfire “на всякий случай” обычно вывернут наизнанку: начните внутри процесса и позвольте конкретному требованию долговечности или планирования подтянуть вас к более тяжёлому инструменту. Когда вы работаете на встроенных примитивах, мониторьте эти фоновые задачи с помощью health-проверок и метрик, чтобы не лететь вслепую, и убедитесь, что ваши циклы отменяются чисто, без взаимной блокировки при остановке.
Источники
- Background tasks with hosted services in ASP.NET Core — Microsoft Learn
- Implement background tasks with IHostedService and BackgroundService — Microsoft Learn
- Introducing the new IHostedLifecycleService interface in .NET 8 — Steve Gordon
- Hangfire overview and supported storage — Hangfire
- Using Redis storage (throughput note) — Hangfire Documentation
- Using SQL Server storage — Hangfire Documentation
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.