aspnet-core-background-services-hosted-tasks-channels

Tareas en segundo plano en ASP.NET Core

  • 5 min

Un BackgroundService es un servicio hospedado que ejecuta trabajo en segundo plano dentro de una aplicación .NET.

Cuando un usuario se registra, quizá tengamos que hacer tres cosas:

  1. Guardar el usuario en la base de datos (10ms).
  2. Enviar un email de bienvenida (1s - 3s).
  3. Redimensionar su foto de perfil (500ms).

Si haces todo esto de forma secuencial (await), el usuario estará mirando una pantalla de carga durante casi 4 segundos. En tiempos de internet, eso es una eternidad.

Lo ideal sería:

Guardar en BBDD.

Responder al usuario: “200 OK - ¡Bienvenido!”.

En segundo plano: enviar el email y procesar la foto.

En ASP.NET Core, esto se hace mediante los Hosted Services (servicios hospedados). Son pequeños procesos que corren junto a tu API web, dentro del mismo proceso.

Vamos a implementarlos usando el patrón productor-consumidor con Channels.

El concepto básico: BackgroundService

.NET nos da una clase base llamada BackgroundService. Cualquier clase que herede de ella y se registre en el sistema, arrancará cuando arranque la API y morirá cuando se pare la API.

Es perfecto para tareas cronometradas (ej: “Borrar logs viejos cada hora”).

using Microsoft.Extensions.Hosting;

public class LimpiadorService : BackgroundService
{
    private readonly ILogger<LimpiadorService> _logger;

    public LimpiadorService(ILogger<LimpiadorService> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Limpiando archivos temporales...");

            // Simula trabajo
            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
        }
    }
}
Copied!

Para activarlo, lo añadimos en Program.cs:

builder.Services.AddHostedService<LimpiadorService>();
Copied!

Comunicar la API con el servicio

El ejemplo anterior es un bucle infinito. Pero, ¿cómo le decimos al servicio: “Oye, envía un email a ESTE usuario ahora” desde un Controlador?

No conviene inyectar el BackgroundService directamente en el controlador. Necesitamos un intermediario, un buzón donde el controlador deje mensajes y el servicio los recoja.

Ese buzón es una Cola (Queue). En .NET podemos crear esta cola en memoria con System.Threading.Channels.

Implementar la cola

Vamos a crear una clase que actúe de cola. Será un Singleton para que sea la misma instancia para toda la aplicación.

using System.Threading.Channels;

public interface IBackgroundTaskQueue
{
    // Método para que el Controlador meta tareas (Productor)
    ValueTask QueueBackgroundWorkItemAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default);

    // Método para que el Servicio saque tareas (Consumidor)
    ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken);
}

public class BackgroundTaskQueue : IBackgroundTaskQueue
{
    // Channel es una colección thread-safe optimizada
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue;

    public BackgroundTaskQueue(int capacity)
    {
        // Bounded: Si la cola se llena, el productor espera. Evita que la RAM explote.
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait
        };
        _queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options);
    }

    public async ValueTask QueueBackgroundWorkItemAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        await _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
    {
        return await _queue.Reader.ReadAsync(cancellationToken);
    }
}
Copied!

El worker que procesa la cola

Ahora creamos el BackgroundService que espera nuevos elementos y los ejecuta.

public class QueuedHostedService : BackgroundService
{
    private readonly IBackgroundTaskQueue _taskQueue;
    private readonly ILogger<QueuedHostedService> _logger;

    public QueuedHostedService(IBackgroundTaskQueue taskQueue, ILogger<QueuedHostedService> logger)
    {
        _taskQueue = taskQueue;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Servicio de Cola Iniciado");

        while (!stoppingToken.IsCancellationRequested)
        {
            // Espera aquí hasta que haya algo en la cola
            var workItem = await _taskQueue.DequeueAsync(stoppingToken);

            try
            {
                // Ejecuta la tarea
                await workItem(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error ejecutando la tarea en segundo plano");
            }
        }
    }
}
Copied!

Registrar todo en Program.cs

// 1. La cola debe ser Singleton (Buzón único)
builder.Services.AddSingleton<IBackgroundTaskQueue>(ctx =>
{
    return new BackgroundTaskQueue(100); // Capacidad de 100 tareas
});

// 2. El servicio que procesa (Worker)
builder.Services.AddHostedService<QueuedHostedService>();
Copied!

Uso desde el controlador

¡Ya está todo listo! Ahora, cuando un usuario se registra, encolamos el envío de email y devolvemos respuesta inmediata.

[HttpPost("registro")]
public async Task<IActionResult> RegistrarUsuario(
    UsuarioDto dto,
    CancellationToken requestAborted)
{
    // 1. Guardar en BBDD (Rápido)
    _repo.Guardar(dto);

    // 2. Encolar tarea pesada (Email)
    // Inyectamos IBackgroundTaskQueue en el constructor
    await _queue.QueueBackgroundWorkItemAsync(
        async token =>
        {
            // Simular envío de email lento
            await Task.Delay(2000, token);
            Console.WriteLine($"Email de bienvenida enviado a {dto.Email}");
        },
        requestAborted);

    // 3. Responder al usuario YA
    return Accepted(); // 202 Accepted (La petición fue aceptada para procesamiento)
}
Copied!

No captures servicios Scoped de la petición dentro de una tarea en segundo plano. Si la tarea necesita un DbContext u otro servicio scoped, crea un scope dentro del worker con IServiceScopeFactory.

Cuándo usar esto o una cola persistente

Lo que hemos hecho es una solución In-Memory.

  • Ventaja: Es gratis, rápido y no necesita infraestructura extra.
  • Desventaja: Si reinicias el servidor (o se cae la API), las tareas que estaban en la cola se pierden.

Usa esta solución para:

  • Enviar emails no críticos.
  • Procesar imágenes.
  • Tareas de mantenimiento sencillas.

Usa Hangfire o RabbitMQ (Colas persistentes) para:

  • Cobros de tarjetas de crédito.
  • Procesos críticos que NO se pueden perder bajo ningún concepto.
  • Tareas que deben reintentarse automáticamente si fallan 5 veces.