Start Debugging

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).

Матрица возможностей

Это та таблица, ради которой вы пришли. Сначала прочитайте строку “Переживает перезапуск”; именно она разделяет поле.

ВозможностьIHostedServiceBackgroundServiceHangfire
Встроено в .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 во время остановки. Две детали кусают разработчиков достаточно часто, чтобы их выделить:

Регистрируйте любую из форм одинаково:

// .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, когда:

Выбирайте чистый IHostedService (или IHostedLifecycleService), когда:

Оба работают на каждом экземпляре вашего приложения. Если вы масштабируетесь до трёх реплик, ваш BackgroundService работает трижды, параллельно, без координации. Для поллера без состояния это нормально. Для “отправить ночное письмо со счетами один раз” это баг.

Когда выбирать Hangfire

Выбирайте 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 в день.

Деталь, которая решает за вас

Два требования заканчивают спор до того, как в дело вступают предпочтения:

  1. “Это не должно теряться, если приложение перезапустится.” Если задача отбрасывается при деплое и это реальный баг (списание платежа, письмо-подтверждение, доставка вебхука), вам нужно долговечное хранилище, а это означает Hangfire (или настоящий брокер сообщений). Никакое вычитывание в StopAsync не заставит BackgroundService пережить kill -9 или отказ узла. Внутрипроцессные варианты держат работу в памяти; память умирает вместе с процессом.

  2. “Это должно выполниться ровно один раз на моих репликах.” BackgroundService работает на каждом экземпляре. Если вы масштабируетесь горизонтально, а задача не идемпотентна, вы получаете дублирующуюся работу. Модель worker’ов Hangfire с общим хранилищем даёт единственное выполнение бесплатно. Внутрипроцессный эквивалент - распределённая блокировка, которую нужно построить и сделать правильно.

Если ни одно из двух требований не применимо (работа внутрипроцессная, толерантная к потерям и либо выполняется один раз, потому что вы запускаете один экземпляр, либо естественно идемпотентна), то добавление Hangfire - это уплата налога базе данных ни за что. Используйте BackgroundService.

Распространённый и правильный гибрид: держите долговечные расписание и повторы в Hangfire, но пусть тело повторяющейся задачи просто ставит в очередь во внутрипроцессный Channel, который вычитывает BackgroundService. Hangfire гарантирует, что задача сработает один раз и переживёт перезапуски; Channel даёт быструю, учитывающую обратное давление внутрипроцессную пропускную способность. Вы получаете оба свойства, не прогоняя каждый элемент через хранилище.

Рекомендация, повторно

По умолчанию используйте BackgroundService для всего, что зацикливается внутри процесса. Обращайтесь к чистому IHostedService или IHostedLifecycleService только тогда, когда вам конкретно нужен порядок запуска или хуки до/после остановки. Принимайте Hangfire в тот момент, когда задача должна пережить перезапуск, выполняться по расписанию, автоматически повторяться или выполняться ровно один раз на нескольких экземплярах, и примите базу данных, которую он приносит, как цену этих гарантий. Инстинкт хвататься за Hangfire “на всякий случай” обычно вывернут наизнанку: начните внутри процесса и позвольте конкретному требованию долговечности или планирования подтянуть вас к более тяжёлому инструменту. Когда вы работаете на встроенных примитивах, мониторьте эти фоновые задачи с помощью health-проверок и метрик, чтобы не лететь вслепую, и убедитесь, что ваши циклы отменяются чисто, без взаимной блокировки при остановке.

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад