Las ventajas que te ofrece Microsoft Azure y el mundo.NET

Asincronía en C#: Bloqueos, contextos y tareas.

Asincronía en Tasks. Bloqueos, contextos y tareas.

“¡Recórcholis! ¡Mi fantástico método HagoCosasChulasAsync bloquea mi aplicación!”.

Si has utilizado la Asincronía basada en Tasks (TAP) en aplicaciones algo complejas, esta frase te sonará. Posiblemente, después de entrar en pánico y ojear Stack Overflow hayas dado con la panacea a todos tus problemas: el método ConfigureAwait que nos expone la clase Task. Pasándole a este método “false” como parámetro y añadiéndolo a lo largo de todas las Tasks, todo vuelve a funcionar como esperabas.

Aunque esto solucione nuestros bloqueos, no está de más saber por qué suceden. También es importante el porqué la solución pasa por añadir este método a nuestras llamadas asíncronas. En este post vamos a intentar aclarar algunos de los conceptos claves del TAP para ahorrar dolores de cabeza (y canas) a los desarrolladores que tienen la fortuna de usarlo en sus aplicaciones.

Qué es una Task


Para comenzar a entender realmente qué hay debajo de nuestro fantástico código asíncrono, debemos familiarizarnos con las Tasks. Según la documentación, una Task representa una operación asíncrona. En nuestro contexto, prácticamente es todo lo que necesitamos saber. Es una promesa de que algo se va a ejecutar y, si utilizamos Task<T>,  supone que se va a devolver un resultado tipo T. Podemos comprobar el estado de estas tareas mediante la propiedad Status o también las propiedades IsCompleted, IsFaulted o IsCanceled.

Cuando creamos una Task de cualquier forma que no sea mediante constructor, también la iniciamos, es decir, empezará a ejecutarse en segundo plano automáticamente y el flujo de ejecución del programa no se bloqueará.

Para esperar a que esta tarea finalice y recuperar su resultado (si lo tiene), debemos utilizar la palabra clave await. Nunca usaremos el método Wait ni accederemos al resultado mediante Result, ya que esto es una causa común de bloqueos.

// Creamos una tarea
Task<int> myTask = Task.FromResult(1000);

// Podemos consultar el estado
// Los posibles valores están definidos en el enum TaskStatus
var taskStatus = myTask.Status;

// Existen propiedades booleanas para controlar los estados
var isTaskFaulted = myTask.IsFaulted;
var isTaskCanceled = myTask.IsCanceled;
var isTaskCompleted = myTask.IsCompleted;

// Esperaremos a que terminen las tareas usando await
var awaitedResult = await myTask;

// Nunca en código asíncrono lo haremos de esta forma
myTask.Wait();
var result = myTask.Result;

¿Qué ocurre cuando uso Async y Await?

Por un lado, tenemos la palabra clave async, que añadimos a la definición de nuestro método y que lo marcará como asíncrono. Esto nos permite utilizar la instrucción await en su código. Ni optimiza nada, ni hace magia con el compilador. Simplemente informa que await puede o no aparecer en el código contenido en el método.

Por otra parte, aparece la fantástica await. Aquí ya hay un poco más de miga. El uso de esta palabra clave delante de una llamada asíncrona que devuelve un Awaitable (una operación asíncrona, que normalmente será una Task), detiene la ejecución del método asíncrono actual y hace un return del método.

Este método se parará hasta que nuestro Awaitable finalice y sólo entonces retomará su ejecución. Para poder continuar, recapturará el contexto en el que se ejecutaba el método antes de dicha parada.

// La palabra clave async nos permite usar await
// Si la quitamos, el código no compilará
public async Task<string> HolaAsync()
{
    // Await devuelve el control a la aplicación
    // hasta que se complete la tarea
    var saludo = await SaludarAsync("Hola");

    // Aquí volvemos a estar en el contexto previo
    // a la llamada a SaludarAsync
    return saludo;
}

// Solo podemos lanzar la tarea al no marcar
// el método como asíncrono
public Task<string> SaludarAsync(string saludo)
{
    // Task.Run encola una nueva tarea a partir del delegado
    return Task.Run(() => { return saludo; });
}

La magia del SynchronizationContext

Cadena de montajeHemos comentado que al retomar la ejecución de un método asíncrono recapturamos el contexto en el que se ejecutaba. Pero, ¿qué es un contexto de sincronización?

De forma muy resumida no es más que una especie de mensajero que permite a hilos de ejecución comunicarse entre si. Cada parte del framework puede necesitar que este contexto se comporte de forma diferente, así que se nos proporcionan varias implementaciones de este contexto. Por ejemplo, tenemos AspNetSynchronizationContext, que es propio de AspNet o DispatcherSynchronizationContext, que es el que se utiliza en aplicaciones WPF o Silverlight.

Para más información sobre esto, podemos acudir a este fantástico artículo de Stephen Cleary.

Os preguntaréis por qué nos importa esto. Pues bien, volvamos al principio. Hemos comentado que una de las maneras más efectivas para evitar bloqueos en nuestro código asíncrono es utilizar el método ConfigureAwait cuando esperemos el resultado de una Task, pasándole false como parámetro. Esto provoca que el awaiter no capture el contexto de sincronización. No debemos olvidar que, al no capturar el contexto, no podremos acceder a él en el código que haya después de la llamada a ConfigureAwait. Esto es importante, por ejemplo, en aplicaciones de escritorio o al trabajar con Request en AspNet.

public async Task<string> HolaAsync()
{
    // Obtendríamos el usuario sin problema
    var userName = HttpContext.Current.User.Identity.Name;

    // Hacemos la llamada con ConfigureAwait en false
    var saludo = await SaludarAsync($"Hola {userName}")
        .ConfigureAwait(false);

    // El código fallará al llegar a esta linea.
    // Hemos especificado que no queríamos capturar el contexto
    // y al volver a entrar, este contexto Http no existe
    var isUserAdmin = HttpContext.Current.User.IsInRole("Admin");
    
    return isUserAdmin ? saludo.ToUpper() : saludo;
}

Evitando los bloqueos

Aunque usar ConfigureAwait arregle nuestros problemas de forma casi mágica la mayoría de veces, no es la mejor práctica para evitar los bloqueos.

¿Qué debemos hacer entonces? Pues para evitar los bloqueos, lo mejor es no bloquear. Aunque parezca una tontería, realmente la manera más fácil de evitarnos problemas es hacer que nuestro código sea completamente asíncrono. Veamos un ejemplo:

// Capa inferior
public class GreetingService
{
    private readonly IGreetingRepository greetingRepository;

    public async Task<string> ObtenerSaludo(string userName)
    {
        // Devolvemos el control
        return await greetingRepository.GetByUserAsync(userName);
    }
}

// Controlador random WebAPI
public class GreetingController : ApiController
{
    public string Get()
    {
        var saludo = ObtenerSaludo(HttpContext.Current.User.Identity.Name);
        // Esta llamada bloqueará la aplicación
        return saludo.Result;
    }
}

¿Por qué bloquearemos al esperar a que termine “saludo” de forma síncrona?

Pues bien, en el momento en el que llamamos a GetByUserAsync devolvemos el control a la aplicación y, como no hemos indicado mediante ConfigureAwait(false) que no queremos ejecutar la continuación de ObtenerSaludo en el mismo contexto, se quedará esperando a que éste se libere. Esa continuación nunca se producirá, ya que estamos bloqueando síncronamente en el método Get y el contexto es el mismo.

Podemos solucionar este error de dos formas: usando ConfigureAwait(false) y diciendo así que no queremos resumir la ejecución de ObtenerSaludo en el mismo contexto, o bien haciendo el código completamente síncrono.

// Nuevo método Get
public async Task<string> Get()
{
    // No necesitamos ConfigureAwait(false)
    return await ObtenerSaludo(HttpContext.Current.User.Identity.Name);
}

Hay que tener en cuenta las implicaciones de elegir la solución de añadir ConfigureAwait(false).

Si estamos bloqueando en el método que está mas arriba hay que añadirlo a absolutamente todas las Task a lo largo del código afectado. Y con todo, me refiero a nuestro código y al de librerías de terceros, sobre las que no siempre solemos tener el control. En el caso de que seamos nosotros los que escribamos una API o una librería de cualquier tipo, es correcto usar ConfigureAwait(false), ya que no podemos controlar si el que nos llama bloquea o no. Por lo tanto, lo mejor es curarse en salud.

En Resumen

Es importante no conformarnos con aplicar las soluciones de Stack Overflow. Aunque nos faciliten la vida y nos eviten caer en el alcoholismo 😉  En mi opinión es muy importante saber por qué suceden los fallos y por qué la solución es la que se propone. En nuestro caso, aunque ConfigureAwait nos arregla la mayoría de problemas con bloqueos, no es la mejor de las prácticas. Y si no seguimos las buenas prácticas no podemos presumir de ello 🙂

mm

Sobre Juan Carlos Martínez García

Desarrollador de Software, sobre todo en back-end. Tengo tres años de experiencia en desarrollo en tecnología Microsoft, especialmente Sharepoint 2013 y online, ASP.Net y Azure. Estoy certificado como MCSD en Web Applications y App Builder. Me apasiona lo que hay detrás de las tecnologías que utilizamos los desarrolladores a diario, el código limpio y desarrollar pensando en colores. Actualmente aplico todo mi buen rollo trabajando para ENCAMINA.
Esta entrada ha sido publicada en Sin categoría. Enlace permanente.
ENCAMINA, piensa en colores