Start Debugging

BackgroundService vs IHostedService vs Hangfire para tareas en segundo plano en .NET 11

Elige BackgroundService para bucles en proceso, IHostedService puro cuando necesitas control fino del ciclo de vida, y Hangfire cuando las tareas deben sobrevivir a un reinicio. Una matriz de decisión con código y el detalle que decide por ti.

Para trabajo en segundo plano en una aplicación .NET 11, la respuesta corta es: usa BackgroundService para bucles continuos en proceso y consumidores de colas, baja a un IHostedService puro solo cuando necesites un arranque o apagado ordenado y explícito, y recurre a Hangfire en cuanto una tarea tenga que sobrevivir a un reinicio del proceso o haya que programarla para “el próximo martes a las 2 de la madrugada”. Los dos primeros son la misma primitiva de hosting a distinta altitud y no te cuestan nada extra. Hangfire es una dependencia aparte con una base de datos detrás, y esa base de datos es exactamente lo que estás pagando. Este artículo construye la matriz de decisión, muestra el código mínimo de cada opción y señala el único requisito (la durabilidad) que normalmente decide por ti.

Todos los ejemplos apuntan a .NET 11 y C# 14. Los ejemplos de Hangfire usan Hangfire 1.8.x (Hangfire.AspNetCore más Hangfire.SqlServer).

La matriz de características

Esta es la tabla que viniste a ver. Lee primero la fila “Sobrevive a un reinicio”; es la que divide el campo.

CaracterísticaIHostedServiceBackgroundServiceHangfire
Integrado en .NET 11no (NuGet + almacenamiento)
Infraestructura extraningunaningunaSQL Server / Redis / Postgres
Superficie de ciclo de vidaStartAsync/StopAsyncun ExecuteAsyncninguna (tú encolas tareas)
Mejor parapasos de arranque/apagadobucles de larga duracióntareas puntuales y programadas
Sobrevive a un reinicionono
Reintentos ante falloslos escribes túlos escribes túautomáticos, configurables
Programación (cron, retraso)la escribes túla escribes túintegrada
Se ejecuta en varias instanciascorre en cada instanciacorre en cada instanciaun worker toma cada tarea
Panel / visibilidadningunoningunopanel web integrado
Costogratisgratisnúcleo OSS; licencia Pro a veces

BackgroundService no es una alternativa a IHostedService; es una clase abstracta que lo implementa. Así que la verdadera elección es de dos vías: un servicio de hosting en proceso (en una de sus dos formas) frente a un sistema de tareas durable externo. Vamos en orden.

IHostedService: el contrato de ciclo de vida puro

IHostedService es la interfaz de bajo nivel que el host genérico de .NET llama durante el arranque y el apagado. Tiene exactamente dos métodos:

// .NET 11, C# 14
public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

El host espera (con await) el StartAsync de cada servicio registrado en orden de registro antes de atender la primera solicitud, y espera el StopAsync (hasta HostOptions.ShutdownTimeout, 30 segundos por defecto) antes de que el proceso termine. Esa garantía de orden es la razón para usar la interfaz pura: es el lugar correcto para trabajo que debe completarse antes de que llegue tráfico (precalentar una caché, ejecutar una comprobación de migración única, abrir una conexión de larga vida).

// .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;
}

La trampa con el IHostedService puro es hacer trabajo de larga duración dentro de StartAsync. Si arrancas ahí un bucle infinito y lo esperas con await, el host nunca termina de arrancar. Tienes que disparar el bucle sin esperarlo y rastrear el Task tú mismo, para luego cancelarlo y esperarlo en StopAsync. Esa contabilidad es exactamente lo que BackgroundService existe para eliminar.

Si necesitas un control aún más fino (un gancho que se ejecute después de que cada servicio de hosting haya arrancado, o justo antes de que comience el apagado), .NET 8 añadió IHostedLifecycleService, que extiende IHostedService con StartingAsync/StartedAsync y StoppingAsync/StoppedAsync. Sigue vigente en .NET 11 y es el lugar documentado para una validación entre servicios del tipo “ahora todo está arriba”, como describe el recorrido de la interfaz de Steve Gordon.

BackgroundService: el bucle que realmente quieres

BackgroundService es la clase base abstracta que implementa IHostedService por ti usando el patrón método-plantilla. Sobrescribes un solo método:

// .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);
        }
    }
}

El framework llama a ExecuteAsync desde su propio StartAsync, señala el stoppingToken cuando el host se detiene y espera con await el Task que devuelves durante el apagado. Dos detalles muerden a la gente con suficiente frecuencia como para mencionarlos:

Registra cualquiera de las dos formas de la misma manera:

// .NET 11, C# 14 -- Program.cs
builder.Services.AddHostedService<QueuePump>();      // BackgroundService
builder.Services.AddHostedService<CacheWarmer>();    // raw IHostedService

Un BackgroundService emparejado con un System.Threading.Channel acotado es la cola de tareas en proceso canónica: los productores escriben elementos de trabajo, el servicio los drena. Si alguna vez recurriste a Task.Run desde un controlador, ese es el patrón que en realidad querías: consulta ejecutar trabajo fire-and-forget de forma segura con un BackgroundService y el argumento más amplio a favor de los Channels frente a BlockingCollection.

Cuándo elegir las opciones en proceso

Elige BackgroundService cuando:

Elige IHostedService puro (o IHostedLifecycleService) cuando:

Ambos corren en cada instancia de tu aplicación. Si escalas a tres réplicas, tu BackgroundService corre tres veces, en paralelo, sin coordinación. Para un sondeador sin estado eso está bien. Para “enviar el correo nocturno de facturas una vez”, es un bug.

Cuándo elegir Hangfire

Elige Hangfire cuando se cumpla alguna de estas:

Configuración mínima en .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));

Fíjate en lo que acaba de cambiar: ahora eres dueño de un conjunto de tablas de base de datos que Hangfire gestiona, una cadena de conexión, migraciones de ese esquema entre actualizaciones de Hangfire y un endpoint de panel que debes autorizar. Eso es peso operativo real. Lo asumes deliberadamente, a cambio de una durabilidad y una programación que de otro modo improvisarías mal.

El panorama de throughput, con números reales

El rendimiento rara vez es el eje decisivo aquí, pero vale la pena ser honestos sobre el costo de la durabilidad. Un BackgroundService en proceso drenando un Channel no hace E/S por elemento más allá de tu propio trabajo; la sobrecarga de despacho es en la práctica una llamada a método y no es medible frente al trabajo en sí. Hangfire, en cambio, hace al menos un viaje de ida y vuelta al almacenamiento para desencolar y otro para marcar la finalización por cada tarea.

La propia documentación de Hangfire cuantifica la elección de almacenamiento: cambiar de SQL Server a Redis rinde más de 4 veces el throughput en tareas vacías, según la guía de Redis. Los números absolutos dependen de la latencia de tu almacenamiento, pero la forma es fija: el suelo de Hangfire es “viajes de ida y vuelta a una base de datos”, y el suelo de una cola en proceso es “nada”. Si procesas decenas de miles de elementos triviales por segundo, esa brecha importa y una cola en proceso con Channel gana de calle. Si procesas miles de tareas por minuto que cada una hace trabajo real (llamar a una API, renderizar un PDF), el costo de almacenamiento por tarea desaparece en el ruido y la durabilidad es gratis en la práctica.

La regla que se desprende: no pongas trabajo de alta frecuencia y tolerante a pérdidas a través de Hangfire solo porque está ahí. Un sondeador que revisa una cola cada segundo es un BackgroundService, no 86.400 tareas de Hangfire al día.

El detalle que decide por ti

Dos requisitos terminan el debate antes de que entre la preferencia:

  1. “Esto no debe perderse si la aplicación se reinicia.” Si una tarea se descarta en un despliegue y eso es un bug real (la captura de un pago, un correo de confirmación, la entrega de un webhook), necesitas almacenamiento durable, y eso significa Hangfire (o un broker de mensajes de verdad). Ninguna cantidad de drenaje en StopAsync hace que un BackgroundService sobreviva a un kill -9 o a un fallo de nodo. Las opciones en proceso mantienen el trabajo en memoria; la memoria muere con el proceso.

  2. “Esto debe ejecutarse exactamente una vez en mis réplicas.” Un BackgroundService corre en cada instancia. Si escalas horizontalmente y la tarea no es idempotente, obtienes trabajo duplicado. El modelo de worker con almacenamiento compartido de Hangfire te da una única ejecución gratis. El equivalente en proceso es un lock distribuido que tienes que construir y hacer bien.

Si no aplica ninguno de los dos requisitos (el trabajo es en proceso, tolerante a pérdidas y o bien corre una vez porque ejecutas una sola instancia o es naturalmente idempotente), entonces añadir Hangfire es pagar un impuesto de base de datos a cambio de nada. Usa BackgroundService.

Un híbrido común y correcto: mantén el programa y los reintentos durables en Hangfire, pero deja que el cuerpo de la tarea recurrente simplemente encole en un Channel en proceso que un BackgroundService drena. Hangfire garantiza que la tarea se dispare una vez y sobreviva a los reinicios; el Channel te da un throughput en proceso rápido y consciente de la contrapresión. Obtienes ambas propiedades sin forzar cada elemento a pasar por el almacenamiento.

La recomendación, repetida

Por defecto, usa BackgroundService para cualquier cosa que haga bucles en proceso. Recurre a IHostedService puro o IHostedLifecycleService solo cuando necesites específicamente orden de arranque o ganchos de pre/post apagado. Adopta Hangfire en cuanto una tarea deba sobrevivir a un reinicio, ejecutarse según un programa, reintentar automáticamente o ejecutarse exactamente una vez en varias instancias, y acepta la base de datos que trae como el precio de esas garantías. El instinto de recurrir a Hangfire “por si acaso” suele estar al revés: empieza en proceso y deja que un requisito concreto de durabilidad o programación te arrastre hacia la herramienta más pesada. Cuando corras sobre las primitivas integradas, monitorea esas tareas en segundo plano con health checks y métricas para no volar a ciegas, y asegúrate de que tus bucles se cancelen limpiamente sin interbloqueo en el apagado.

Fuentes

Comments

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

< Volver