Ejecutar acciones en segundo plano en asp.net

  • net-core
  • net
  • api
  • backgroundservice
  • channel
  • dotnet

En el post anterior vimos como lanzar acciones al iniciar y finalizar nuestra aplicación asp.net. Pero hay ocasiones en las que tenemos que lanzar acciones en background que se ejecuten cada cierto tiempo, o que se desencadenen tras cierto evento. En ambos casos podemos utilizar un BackgroundService, que no deja de ser una implementación de IHostedService. Para utilizarlo, es necesario implementar el método ExecuteAsync, que se lanza en el StartAsync y se cancela es StopAsync.

El método ExecuteAsync se lanza como fire and forget, por lo que no es necesario que su ejecución termine para que el servidor esté disponible. Por tanto, las acciones que aquí se añadan no deben ser obligatorias para el correcto inicio de nuestra aplicación.

Realizando acciones en background cada cierto tiempo

El caso más sencillo y, seguramente el más utilizado, con BackgroundService, es el de ejecutar acciones cada cierto tiempo en background.

Tal y como vemos a continuación, basta con utilizar un timer y lanzar nuestra tarea cada vez que el timer.

public class TestTimedBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        Console.WriteLine($"***** => {DateTime.Now} Initializing test timed background service");
        using PeriodicTimer timer = new(TimeSpan.FromSeconds(20));
        while (await timer.WaitForNextTickAsync(stoppingToken))
            await DoAnythingAsync();
    }

    private async Task DoAnythingAsync()
    {
        Console.WriteLine($"***** => {DateTime.Now} Doing background work");
        await Task.Delay(5000);
    }

    public override void Dispose()
    {
        base.Dispose();
        //Dispose of resources here
    }
}

Téngase en cuenta, que BackgroundService implementa IDisposable, por lo que podemos sobreescribir Dispose para liberar todos los recursos que hayamos utilizado.

Si observamos la salida por consola, veremos que nuestra tarea se ejecuta cada 20 segundos, tal y como hemos establecido en el timer. Y termina cuando nuestra aplicación es detenida.

***** => 23/10/2023 18:37:33 Initializing test timed background service
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7069
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5019
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /Users/jorgediego/Projects/Side projects/DiCresTasks/TasksWebApi/TasksWebApi
***** => 23/10/2023 18:37:53 Doing background work
***** => 23/10/2023 18:38:13 Doing background work
***** => 23/10/2023 18:38:33 Doing background work
***** => 23/10/2023 18:38:53 Doing background work

El registro de este servicio en nuestro IServiceCollection se hace añadiendo un hosted service, ya que el BachgroundService no deja de ser eso.

...
builder.Services.AddHostedService<TestTimedBackgroundService>();
...

Encolando acciones para ser ejecutadas en background

La otra forma en la que suelo utilizar los servicios en segundo plano, es ejecutándolos a partir de un channel. Es decir, cuando necesito que el servicio se lance, encolo una petición en el channel, que se ejecutará en segundo plano.

Para encolar y desencolar tareas, me gusta hacer un servicio singleton cuya única tarea es esa. Además, a la hora de encolar, me gusta tener un método por cada tarea que puedo lanzar en background. Podría aceptar una tarea como parámetro para hacer el servicio más flexible, pero al ser tareas que se ejecutarán en background, me gusta que mi servicio sea algo más restrictivo en pos de tener un mayor control.

ublic interface ITestTaskQueue
{
    Task TestOneQueueAsync();
    Task TestTwoQueueAsync();
    ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken = default);
}

En el ejemplo anterior, permito encolar 2 tareas distintas y tengo un método para ir desencolándolas. La implementación de este servicio quedaría de la siguiente manera.

public class TestTaskQueue : ITestTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> queue;

    public TestTaskQueue()
    {
        var options = new UnboundedChannelOptions
        {
            SingleReader = true,
            SingleWriter = false
        };
        queue = Channel.CreateUnbounded<Func<CancellationToken, ValueTask>>(options);
    }

    public async Task TestOneQueueAsync()
    {
        await queue.Writer.WriteAsync(async cancellationToken =>
        {
            Console.WriteLine($"***** => {DateTime.Now} executing test one task...");
            await Task.Delay(5_000, cancellationToken);
            Console.WriteLine($"***** => {DateTime.Now} test one task executed");
        });
    }

    public async Task TestTwoQueueAsync()
    {
        await queue.Writer.WriteAsync(async cancellationToken =>
        {
            Console.WriteLine($"***** => {DateTime.Now} executing test two task...");
            await Task.Delay(8_000, cancellationToken);
            Console.WriteLine($"***** => {DateTime.Now} test two task executed");
        });
    }

    public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken = default)
    {
        var workItem = await queue.Reader.ReadAsync(cancellationToken);
        return workItem;
    }
}

Al tratarse de un ejemplo, lo único que hacen las tarea que encolo es imprimir en consola un par de mensajes cuando la tarea se inicia y finaliza, simulando un tiempo de 5 y 8 segundos de espera cada una de ellas.

La última pieza que falta del puzzle es el background service cuya tarea es ir desencolando tareas y ejecutándolas.

public class TestQueuedService : BackgroundService
{
    private readonly ITestTaskQueue taskQueue;

    public TestQueuedService(ITestTaskQueue taskQueue)
    {
        this.taskQueue = taskQueue;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await BackgroundProcessing(stoppingToken);
    }

    private async Task BackgroundProcessing(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var workItem = await taskQueue.DequeueAsync(stoppingToken);
                await workItem(stoppingToken);
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine($"***** => {DateTime.Now} Operation cancelled because host is shutting down");
            }
            catch (AggregateException)
            {
                Console.WriteLine($"***** => {DateTime.Now} Aggregate exception");
            }
            catch (Exception)
            {
                Console.WriteLine($"***** => {DateTime.Now} Unhandled exception");
            }
        }
    }
}

Teniendo todas las piezas, solo faltaría el registro de interfaz y la implementación como un singleton, y del background service

...
builder.Services.AddSingleton<ITestTaskQueue, TestTaskQueue>();
builder.Services.AddHostedService<TestQueuedService>();
...

Encolando tareas

Con todo lo anterior, tenemos montado el mecanismo para ejecutar tareas en segundo plano a medida que se vayan añadiendo al canal. Pero faltaría ver de qué manera se pueden encolar esas tareas. Para mostrarlo, he creado un controlador sencillo, con dos endpoints. Cada uno de ellos, encola una de las tareas que se pueden ejecutar en segundo plano.

[ApiController]
public class TestController : BaseController
{
    private readonly ITestTaskQueue _testTaskQueue;

    public TestController(ITestTaskQueue testTaskQueue)
    {
        _testTaskQueue = testTaskQueue;
    }

    [HttpGet]
    [Route("testOne")]
    public async Task<IActionResult> TestOne(CancellationToken cancellationToken = default)
    {
        await _testTaskQueue.TestOneQueueAsync();
        return Ok();
    }

    [HttpGet]
    [Route("testTwo")]
    public async Task<IActionResult> TestTwo(CancellationToken cancellationToken = default)
    {
        await _testTaskQueue.TestTwoQueueAsync();
        return Ok();
    }
}

Ambito de los servicios utilizados

Al tratarse de una tarea en segundo plano, no se dispone de un scope. Por lo que, si queremos recibir inyectados algún servicio registrado como scope, se producirá una excepción. No obstante, sí que podemos recibir un IServiceScopeFactory, crear un scope y acceder a los servicios en el momento que lo necesitemos.

public class TestTimedBackgroundService : BackgroundService
{
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public TestTimedBackgroundService(IServiceScopeFactory serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        Console.WriteLine($"***** => {DateTime.Now} Initializing test timed background service");
        using PeriodicTimer timer = new(TimeSpan.FromSeconds(20));
        while (await timer.WaitForNextTickAsync(stoppingToken))
            await DoAnythingAsync();
    }

    private async Task DoAnythingAsync()
    {
        ...
        using var scope = _serviceScopeFactory.CreateScope();
        var services = scope.ServiceProvider;
        var environment = services.GetService<IConfiguration>();
        ...
    }
}

El objetivo de este post, era enseñar las formas en las que suelo utilizar los BackgroundService. Espero haberlo conseguido y que te haya servido de ayuda.

Foto de Markus Spiske en Unsplash