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, 9 de abril de 2019
Manos sobre teclado Hace unas semanas leía el post C# Async Antipatterns de Mark Heath, y encontré en él problemas en la implementación de código asíncrono que, coincidiendo con el autor, creo que son bastante frecuentes.

Como la asincronía ha llegado para quedarse y aún hay desarrolladores que no lo tienen claro del todo, he pensado que sería interesante traducir y republicar aquí el post, por supuesto, con permiso expreso de su autor (thank you, Mark! ;))

¡Vamos allá!

Antipatrones de asincronía en C#

Las palabras clave async y await han hecho un gran trabajo simplificando la forma de escribir código asíncrono en C#, pero, desafortunadamente, no pueden protegernos de hacer las cosas mal. En este artículo me gustaría recoger algunos de los errores de codificación o antipatrones más comunes relativos a asincronía que suelo encontrar en revisiones de código.

1. Olvidar el await

Cuando llamamos a un método que retorna un objeto Task o Task<T>, no deberíamos ignorar su valor de retorno. Esto decir, la mayor parte de las veces deberemos utilizar await para esperar su resultado, aunque hay algunas ocasiones en las que podríamos necesitar almacenar el objeto Task para esperar a su finalización más adelante.

Observad que en el siguiente ejemplo invocamos a Task.Delay(), pero dado que no esperamos su finalización, el mensaje "Después" es mostrado inmediatamente porque la llamada Task.Delay(1000) simplemente retorna un objeto Task que finalizará un segundo más tarde, pero, dado que nadie está esperando a que esta tarea sea completada, la ejecución continúa sin detenerse:
Console.WriteLine("Antes");
Task.Delay(1000);
Console.WriteLine("Después");
Cuando cometemos este error en un método que retorna un objeto Task y está marcado como asíncrono con la palabra clave async, el compilador nos dará un error bastante descriptivo:
Because this call is not awaited, execution of the current method continues before the call is completed. 
Consider applying the 'await' operator to the result of the call.
Sin embargo, en código síncrono o métodos que retornan tareas sin estar marcados como async, el compilador de C# no protestará y nos permitirá hacerlo sin problema. Por tanto, debemos estar atentos para que este error no pase desapercibido.

2. Ignorar tareas asíncronas

A veces nos interesa ignorar deliberadamente el resultado de una llamada asíncrona porque no queremos esperar a que ésta se complete. Por ejemplo, podría tratarse de un tarea de largo tiempo de ejecución que queremos que se realice en segundo plano, mientras en primer plano continuamos con otra cosa.

A menudo veo código como el siguiente:
// Hacer algo en segundo plano - no queremos esperar a que finalice
var ignoredTask = DoSomethingAsync();
El peligro de utilizar este enfoque es que, dado que no esperamos la finalización de la tarea, las excepciones que pudieran ser lanzadas desde DoSomethingAsync() no serían capturadas en ningún momento. En este caso, lo mejor que puede pasar es que no nos enteremos de que la operación en segundo plano falló; lo peor, es que incluso podría tumbar el proceso completo y provocar la finalización de la aplicación.

Por tanto, debemos utilizar esta técnica con mucha precaución, y siempre asegurar que el método invocado gestiona apropiadamente las excepciones. A menudo veo su uso en aplicaciones en la nube, y en muchas ocasiones tiendo a refactorizarlo enviando un mensaje a una cola cuyo handler realiza la tarea en segundo plano de forma totalmente independiente.

3. Utilizar métodos async void

A menudo os encontraréis escribiendo un método síncrono (es decir, un método que no retorna una instancia de Task o Task<T>) en cuyo interior os interesará llamar a un método asíncrono (que retorne un Task). Sin embargo, sabemos que no podemos utilizar la palabra clave await sin marcar el método como async, por lo que tendremos que buscar otras fórmulas. Básicamente hay dos formas de conseguirlo, y las dos son arriesgadas.

La primera es cuando estamos en un método void. En este caso, el compilador de C# nos permitirá añadir async a la firma del método, por lo que podremos utilizar await sin problema:
public async void MyMethod()
{
    await DoSomethingAsync();
}
El problema en este caso es que los consumidores de MyMethod() no tienen forma de esperar a que este método finalice su ejecución, pues no tienen acceso al objeto Task que retorna la llamada a DoSomethingAsync(). Por lo tanto, básicamente estaremos ignorando la tarea asíncrona de nuevo.

Sin embargo, hay algunos casos de uso válidos para los métodos async void. El mejor ejemplo lo encontraríamos en los manejadores de eventos de aplicaciones Windows Forms o WPF. Los event handlers retornan siempre void, por lo que no podemos hacer que retornen el objeto Task con la tarea asíncrona.

Por tanto, no hay ningún problema en utilizar un código como el siguiente:
public async void OnButton1Clicked(object sender, EventArgs args)
{
    await LoadDataAsync();
    // update UI
}
Pero aparte de esto, en la mayoría de los casos es recomendable evitar métodos async void. Si podéis hacer que vuestros métodos retornen Task, deberíais hacerlo.

4. Bloquear tareas con .Result o .Wait()

Otra forma bastante habitual de consumir métodos asíncronos desde métodos síncronos es utilizando la propiedad .Result o llamando a Wait() sobre el objeto Task que representa a la tarea asíncrona.

La propiedad Result espera a que la tarea finalice y retorna su resultado, lo cual a priori puede parecer bastante útil. Podemos utilizarlo como se muestra a continuación:
public void SomeMethod()
{
    var customer = GetCustomerByIdAsync(123).Result;
}
Sin embargo, esto genera varios problemas. El primero es que utilizar llamadas como Result o Wait() hace que el hilo de ejecución quede bloqueado y no pueda ser destinado a otros menesteres mientras espera la finalización de la tarea asíncrona. Pero lo que es peor, la mezcla de código asíncrono con llamadas a .Result (o Wait()) abre la puerta a problemas de interbloqueos realmente terribles.

Por lo general, siempre que necesitéis llamar a un código asíncrono desde un método debéis aseguraros de que éste también sea asíncrono. Sin duda, esto puede suponer un cierto esfuerzo adicional y deriva en muchos cambios en cascada para introducir asincronía en toda la cadena de llamadas, sobre todo en una base de código extensa, pero esto es preferible al riesgo de introducir deadlocks.

Puede haber casos en los que no es posible hacer el método asíncrono. Por ejemplo, si queremos invocar un código asíncrono desde el constructor de una clase, esto no será posible, aunque normalmente podremos rediseñar la clase para no necesitarlo.

Por ejemplo, en lugar de esto:
class CustomerHelper
{
    private readonly Customer customer;
    public CustomerHelper(Guid customerId, ICustomerRepository repo)
    {
        customer = repo.GetAsync(customerId).Result; // avoid!
    }
}
En su lugar, podríamos utilizar una factoría asíncrona para instanciar el objeto:
class CustomerHelper
{
    public static async Task<CustomerHelper> CreateAsync(Guid customerId, ICustomerRepository repo)
    {
        var customer = await repo.GetAsync(customerId);
        return new CustomerHelper(customer)
    }

    private readonly Customer customer;
    private CustomerHelper(Customer customer)
    {
        this.customer = customer;
    }
}
Otros escenarios en los que nos encontramos con las manos atadas es cuando implementamos una interfaz de terceros que es síncrona y no puede ser modificada. Por ejemplo, me he encontrado en esta situación al utilizar IDisposable o implementar el atributo ActionFilterAttribute de ASP.NET MVC. En estos casos hay que ser muy creativos para idear una solución, o simplemente asumir que necesitaremos realizar llamadas bloqueantes y prepararnos para introducir muchas llamadas a ConfigureAwait(false) para protegernos de los bloqueos (hablaremos de esto más adelante).

Las buenas noticias son que, en el desarrollo actual, es cada vez más raro encontrar escenarios en los que sea necesario bloquear una tarea. Por ejemplo, desde C# 7.1 podemos declarar métodos async Main para aplicaciones de consola y ASP.NET Core está diseñado con la asincronía en mente desde sus orígenes, mucho más de lo que lo estaba ASP.NET MVC.

5. Mezclar ForEach() con métodos asíncronos

La clase List<T> tiene un "útil" método llamado ForEach(), que permite ejecutar un Action<T> sobre cada elemento de la lista. Si habéis visto algunas de mis ponencias sobre LINQ ya conoceréis mis opiniones en contra de este método, pues fomenta una gran variedad de malas prácticas (podéis leer este artículo para conocer algunas de las razones para evitar el uso de ForEach()).

Pero aparte de esto, una aplicación especialmente peligrosa de ForEach() que he visto es utilizarlo para invocar métodos asíncronos. Por ejemplo, imaginemos que queremos enviar un email a todos los clientes de la siguiente manera:
customers.ForEach(c => SendEmailAsync(c));
¿Cuál es el problema con este código? Pues bien, lo que estamos haciendo es exactamente lo mismo que en el siguiente bucle foreach:
foreach(var c in customers)
{
    SendEmailAsync(c); // The return task is ignored
}
Hemos generado un objeto Task por cada cliente, pero no hemos esperado a que ninguna de estas tareas finalice.

A veces he visto desarrolladores que intentan solucionarlo introduciendo async y await en la lambda:
customers.ForEach(async c => await SendEmailAsync(c));
Esto no supone ninguna diferencia. El método ForEach() acepta un Action<T> que retorna void, por lo que básicamente hemos creado un método async void que, por supuesto, es uno de los antipatrones que hemos comentado anteriormente, pues el consumidor no tiene forma de esperar su finalización.

Y entonces, ¿cómo solucionamos esto? Bien, personalmente preferiría sustituir ese código por un bucle foreach explícito:
foreach(var c in customers)
{
    await SendEmailAsync(c);
}
Algunos desarrolladores preferirían hacer esto en un método extensor, llamado por ejemplo ForEachAsync(), lo que permitiría escribir código como el siguiente:
await customers.ForEachAsync(async c => await SendEmailAsync(c));
Pero en definitiva, no mezcléis List<T>.Foreach() (o Parallel.Foreach, que de hecho tiene el mismo problema) con métodos asíncronos.

6. Exceso de paralelización

En algunas ocasiones, podemos identificar una serie de tareas que se realizan de forma secuencial y forman parte de un cuello de botella en el rendimiento de nuestras aplicaciones. Por ejemplo, imaginad un código que procesa pedidos secuencialmente como el mostrado a continuación:
foreach(var o in orders)
{
    await ProcessOrderAsync(o);
}
Pues bien, a veces encuentro desarrolladores que intentan acelerar este proceso de la siguiente manera:
var tasks = orders.Select(o => ProcessOrderAsync(o)).ToList();
await Task.WhenAll(tasks);
Lo que conseguimos con esto es llamar a ProcessOrderAsync() para cada pedido, y almacenar en una lista cada uno de los objetos Task retornados. Tras ello, esperamos a que todas estas tareas finalicen.

En efecto, este código funciona, pero, ¿qué ocurriría si existieran 10.000 pedidos? Habríamos inundado el thread pool con miles de tareas, quizás evitando que otras tareas del sistema puedan ser realizadas. Además, si ProcessOrderAsync() hiciera a su vez llamadas a otros servicios externos como bases de datos o microservicios, estaríamos saturándolos con un gran volumen de llamadas.

¿Y cuál sería el enfoque correcto en este caso? Bueno, al menos deberíamos considerar limitar el número de tareas concurrentes que pueden llamar a ProcessOrderAsync() al mismo tiempo. En este artículo describí distintas fórmulas para conseguir esto.

Cuando veo este código en una aplicación en la nube, a veces es una señal de que tenemos que introducir algún sistema de mensajería para que la carga pueda ser dividida en bloques y gestionada por lotes desde más de un servidor.

7. Efectos laterales del código no thread safe

Si alguna vez habéis echado un vistazo a la programación funcional (lo que recomiendo que hagáis incluso si no tenéis intención de cambiar de lenguaje), os habréis topado con el concepto de funciones puras. Conceptualmente, las funciones puras son aquellas que no son sensibles a efectos laterales; toman unos datos de entrada y retornan otros de salida, pero no realizan ninguna mutación de estado en su interior. Las funciones puras ofrecen muchos beneficios, incluyendo su inherente seguridad en entornos multiproceso.

A menudo encuentro métodos como el siguiente, donde enviamos una lista o un diccionario a un método que los modifica de alguna forma:
public Task ProcessUserAsync(Guid id, List<User> users)
{
    var user = userRepository.GetAsync(id);
    // do other stuff with the user
    users.Add(user);
}
El problema es que este código es peligroso porque no impide que la lista de usuarios pueda ser modificada al mismo tiempo desde otro hilo de ejecución. A continuación podemos ver el mismo método actualizado para eliminar los posibles efectos laterales sobre la lista:
public Task<User> ProcessUserAsync(Guid id)
{
    var user = userRepository.GetAsync(id);
    // do other stuff with the user
    return user;
}
Así, hemos movido la responsabilidad de añadir el usuario a la lista al consumidor del método, que tendrá muchas más posibilidades de asegurarse de que la lista es accedida únicamente desde un thread.

8. Ausencia de ConfigureAwait(false)

ConfigureAwait() no es un concepto especialmente fácil de comprender para nuevos desarrolladores, pero su correcta utilización puede ser crítica si estáis trabajando sobre una base de código síncrono que utiliza Result o Wait() para esperar la finalización de tareas asíncronas.

Sin entrar en mucho detalle, el significado principal de ConfigureAwait(true) es informar de que quiero que el código continúe ejecutándose en el mismo contexto de sincronización cuando mi tarea haya finalizado.

Por ejemplo, en una aplicación WPF, el contexto de sincronización es el UI thread, y dado que sólo es posible actualizar componentes de la interfaz de usuario desde este hilo, casi siempre tendré que utilizar ConfigureAwait(true) en mi código de UI, como se muestra a continuación:
private async void OnButtonClicked()
{
    var data = await File.ReadAllBytesAsync().ConfigureAwait(true);
    this.textBoxFileSize.Text = 
        $"The file is ${data.Length} bytes long"; // needs to be on the UI thread
}
Hoy en día, ConfigureAwait(true) se emplea por defecto en todas partes, por lo que podríamos eliminarlo en el ejemplo anterior y todo seguiría funcionando.

Entonces, ¿para que querríamos utilizar ConfigureAwait(false)? Pues por ejemplo por motivos de rendimiento. No siempre necesitamos continuar la ejecución en el mismo contexto de sincronización, y en estos casos será mejor si no forzamos a que todo el trabajo se haga en un hilo concreto. Por tanto, ConfigureAwait(false) debería utilizarse siempre que no nos importe dónde debe continuarse la ejecución, que es de hecho en muchas ocasiones, especialmente en código de bajo nivel que realice operaciones con archivos o llamadas a través de la red.

Sin embargo, cuando combinamos código que utiliza contextos de sincronización, ConfigureAwait(false) y llamadas a Result o Wait(), aparece el peligro de deadlocks. En estos casos, la fórmula recomendable para evitarlo es acordarse de utilizar ConfigureAwait(false) en todos los lugares donde explícitamente no sea necesario permanecer en el mismo contexto de sincronización.

Por ejemplo, si vamos a crear una biblioteca propósito general para ser distribuida a través de NuGet, sería muy recomendable introducir ConfigureAwait(false) en todas las llamadas asíncronas, porque no podemos estar seguros del contexto en el que serán utilizadas.

Hay algunas buenas noticias en el horizonte. En ASP.NET Core ya no existe el contexto de sincronización, lo que implica que no necesitaremos utilizar ConfigureAwait(false) con este framework, aunque sigue siendo recomendable cuando creemos paquetes NuGet.

Pero si estáis trabajando en proyectos que corren riesgo de deadlocks, deberéis estar muy atentos y añadir las llamadas a ConfigureAwait(false) en todas partes.

9. Ignorar la versión asíncrona

En todos los métodos de .NET Framework que tardan cierto tiempo o que efectúan E/S en disco o red, casi siempre existe una versión asíncrona que puede ser utilizada en su lugar. Desafortunadamente las versiones síncronas de estos métodos se mantienen por motivos de retrocompatibilidad, pero rara vez encontraremos buenas razones para utilizarlas.

Por ejemplo, es siempre mejor utilizar Task.Delay() que Thread.Sleep(), dbContext.SaveChangesAsync() que dbContext.SaveChanges(), o fileStream.ReadAsync() que fileStream.Read(), pues de esta forma se liberarán hilos del thread pool para poder realizar otras tareas, permitiendo que tus aplicaciones procesen un mayor volumen de solicitudes.

10. Usar try/catch sin await

Existe una optimización muy práctica que probablemente hayáis utilizado alguna vez. Supongamos que tenemos un método asíncrono muy simple, que sólo hace una llamada asíncrona en la última línea del mismo:
public async Task SendUserLoggedInMessage(Guid userId)
{
    var userLoggedInMessage = new UserLoggedInMessage() { UserId = userId };
    await messageSender.SendAsync("mytopic", userLoggedInMessage);
}
Es esta situación no hay necesidad de utilizar las palabras clave async y await. Podríamos haber hecho simplemente lo siguiente, retornando la tarea directamente:
public Task SendUserLoggedInMessage(Guid userId)
{
    var userLoggedInMessage = new UserLoggedInMessage() { UserId = userId };
    return messageSender.SendAsync("mytopic", userLoggedInMessage);
}
Tras bambalinas, esto produce un código ligeramente más eficiente, puesto que cuando se utiliza la palabra clave await la compilación genera una máquina de estados.

Pero imaginemos que actualizamos el método de la siguiente manera:
public Task SendUserLoggedInMessage(Guid userId)
{
    var userLoggedInMessage = new UserLoggedInMessage() { UserId = userId };
    try
    {
        return messageSender.SendAsync("mytopic", userLoggedInMessage);
    }
    catch (Exception ex)
    {
        logger.Error(ex, "Failed to send message");
        throw;
    }
}
Aunque a primera vista puede parecer correcto, en realidad la cláusula catch no está cumpliendo el cometido que podríamos esperar: no capturará las excepciones que pudieran lanzarse durante la ejecución de la tarea retornada por SendAsync(). Esto se debe a que, en realidad, sólo estamos capturando las excepciones lanzadas mientras se crea la tarea, que es muy distinto.

Si quisiéramos capturar las excepciones lanzadas en cualquier momento durante esa tarea, necesitaríamos de nuevo la palabra clave await:
public async Task SendUserLoggedInMessage(Guid userId)
{
    var userLoggedInMessage = new UserLoggedInMessage() { UserId = userId };
    try
    {
        await messageSender.SendAsync("mytopic", userLoggedInMessage);
    }
    catch (Exception ex)
    {
        logger.Error(ex, "Failed to send message");
        throw;
    }
}
Ahora nuestro bloque catch sí podrá capturar las excepciones lanzadas durante la ejecución de la tarea SendAsync().

Conclusión

Hay muchas formas en la que uno puede provocar problemas implementando código asíncrono, por lo que vale la pena dedicar de tiempo a profundizar en la comprensión sobre threading. En este artículo simplemente he citado alguno de los problemas que encuentro con mayor frecuencia, pero estoy seguro de que podrían ser añadidos muchos más. No dudéis en dejar en los comentarios qué consejos adicionales añadiríais a esta lista.

Si os gustaría aprender más sobre threading en C#, algunos recursos que os puedo recomendar son la reciente charla de Bill Wagner en NDC, el curso Getting Started with Asynchronous Programming in .NET de Filip Ekberg y, por supuesto, cualquier cosa escrita por el experto en asincronía Stephen Cleary.

Links:
Publicado en Variable not found.

2 Comentarios:

Alberto Baigorria dijo...

Excelente entrada Jose Maria! Hace bastante tiempo que vengo introduciéndome con los procesos asíncronos y esta entrada quedo al "toque".

Saludos desde Santa Cruz, Bolivia.

José María Aguilar dijo...

Muchas gracias, me alegro de que te resulte útil!

Saludos!