Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 29 de mayo de 2018
ASP.NET Core Hace algún tiempo hablamos por aquí sobre cómo implementar tareas en segundo plano en ASP.NET Core con IHostedService, una fórmula en principio bastante sencilla proporcionada por el framework para introducir en nuestras aplicaciones servicios, o procesos en segundo plano. Recordaréis que la magia consistía únicamente en implementar el interfaz IHostedService y registrar dicha implementación en el inyector de dependencias.

Sin embargo, aunque podía parecer lo contrario, la implementación correcta del interfaz IHostedService no era una tarea sencilla. De hecho, como se comenta en este issue de Github, IHostedService era un interfaz de muy bajo nivel y, al no quedar claro cómo debía utilizarse en escenarios reales, podría dar lugar a bastantes confusiones y problemas.

Entendiendo IHostedService

Lo mejor para entender cómo funcionan las cosas, sin duda, es abrir el capó. Buceando en las tripas de ASP.NET Core, se puede observar que el componente encargado de lanzar los servicios en segundo plano es HostedServiceExecutor. Este componente recibe en su constructor la colección de objetos de IHostedService registrados en el sistema y se encarga principalmente de dos cosas: arrancar y detener los servicios.
Este componente, a su vez, es utilizado por WebHost, que es la implementación por defecto de IWebHost, el host que se construye por defecto durante la inicialización de la aplicación en el archivo Program.cs. Es en sus métodos StartAsync() y StopAsync() donde se llaman respectivamente a los métodos con el mismo nombre de HostedServiceExecutor.
El método StartAsync() de HostedServiceExecutor, invocado durante el inicio del host, recorre todos los IHostedServices e invoca a su método StartAsync(). Más o menos el código de este proceso (algo simplificado) es el siguiente, donde _services es la colección de IHostedServices registrados en el inyector de dependencias:
...
foreach(var service in _services) 
{
    await service.StartAsync(cancellationToken)
}
El método StartAsync() de cada IHostedService es invocado con un await, lo que implica que el bucle quedará bloqueado en cada iteración hasta que acabe esta llamada. Por tanto, todas las tareas posteriores del inicio de la aplicación como llamar al StartAsync() de otros servicios o incluso lanzar Kestrel no podrán ser realizadas mientras el hilo esté bloqueado.

También en este contexto queda más claro que el token de cancelación que nos llegaba a StartAsync() no tiene como objetivo gestionar la cancelación de la tarea en segundo plano, sino cancelar la propia inicialización. Y lo mismo ocurre con el token recibido en StopAsync(), cuya misión es forzar la finalización de las tareas.

Por tanto, la forma correcta de implementar un servicio en segundo plano en ASP.NET Core 2.0 es algo menos trivial de lo que podría parecer en un principio. Por ejemplo, en el método StartAsync() debemos asegurarnos de iniciar la tarea y retornar rápidamente el control para que el arranque de la aplicación no quede bloqueado, gestionar correctamente los tokens de cancelación, la detención de la tarea, y otros detalles.

La forma correcta de hacerlo en ASP.NET Core 2.1 (y ASP.NET Core 2.0)

Consciente de estas dificultades, el equipo de ASP.NET Core ha introducido en la versión 2.1 la clase BackgroundService, que nos lo pone todo bastante más sencillo. De hecho, es la fórmula recomendada y que debemos utilizar para no tener problemas. Por si tenéis curiosidad por ver qué hace por dentro, ahí va el código actual (lo he limpiado un poco para hacerlo más legible):
public abstract class BackgroundService : IHostedService, IDisposable
{
    private Task _executingTask;
    private readonly CancellationTokenSource _stoppingCts = new CancellationTokenSource();

    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

    public virtual Task StartAsync(CancellationToken cancellationToken)
    {
        _executingTask = ExecuteAsync(_stoppingCts.Token);
        if (_executingTask.IsCompleted)
            return _executingTask;  // If the task is completed then return it

        return Task.CompletedTask;  // Otherwise it's running
    }

    public virtual async Task StopAsync(CancellationToken cancellationToken)
    {        
        if (_executingTask == null)
            return; // Stop called without start

        try
        {
            _stoppingCts.Cancel(); // Signal cancellation to the executing method
        }
        finally
        {
            // Wait until the task completes or the stop token triggers
            await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken));
        }
    }

    public virtual void Dispose()
    {
        _stoppingCts.Cancel();
    }
}
Como podemos ver, esta clase abstracta realiza internamente todos los trabajos de fontanería necesarios para iniciar y parar la tarea correctamente sin bloquear el arranque y utilizando apropiadamente los tokens de cancelación. Nosotros simplemente deberemos sobrescribir su método ExecuteAsync(), donde podremos centrarnos en implementar la lógica propia de nuestra aplicación.

Así, la forma correcta de escribir un servicio utilizando esta clase sería la siguiente; veréis que es bastante más sencillo que haciendo lo propio con IHostedService:
public class MyBackgroundService : BackgroundService
{
    private readonly ILogger<MyBackgroundService> _logger;

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

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        for (var i = 1; !stoppingToken.IsCancellationRequested; i++)
        {
            _logger.LogInformation($"Loop #{i}");
            await Task.Delay(1000, stoppingToken); 
        }
    }
}
Por cierto, recordad que la llamada a Task.Delay() lanzará una excepción de tipo CancelledTaskException si es cancelada, por lo que si ponéis algún código tras el bucle es posible que no se ejecute. No olvidéis usar un try/catch en estos casos.
Por último, fijaos que aunque la clase BackgroundServices se ha introducido en ASP.NET Core 2.1, podéis usarla en vuestras aplicaciones ASP.NET Core 2.0 o 1.x si la incluís manualmente en vuestras aplicaciones, bien copiando y pegando el código que tenéis más arriba, o bien obteniéndolo desde Github.

Publicado en Variable not found.

Aún no hay comentarios, ¡sé el primero!