martes, 9 de abril de 2019
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á!
1. Olvidar el
Cuando llamamos a un método que retorna un objeto
Observad que en el siguiente ejemplo invocamos a
A menudo veo código como el siguiente:
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
A menudo os encontraréis escribiendo un método síncrono (es decir, un método que no retorna una instancia de
La primera es cuando estamos en un método
Sin embargo, hay algunos casos de uso válidos para los métodos
Por tanto, no hay ningún problema en utilizar un código como el siguiente:
4. Bloquear tareas con
Otra forma bastante habitual de consumir métodos asíncronos desde métodos síncronos es utilizando la propiedad
La propiedad
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:
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
5. Mezclar
La clase
Pero aparte de esto, una aplicación especialmente peligrosa de
A veces he visto desarrolladores que intentan solucionarlo introduciendo
Y entonces, ¿cómo solucionamos esto? Bien, personalmente preferiría sustituir ese código por un bucle
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
¿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
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.
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:
8. Ausencia de
Sin entrar en mucho detalle, el significado principal de
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
Entonces, ¿para que querríamos utilizar
Sin embargo, cuando combinamos código que utiliza contextos 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
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
Pero si estáis trabajando en proyectos que corren riesgo de deadlocks, deberéis estar muy atentos y añadir las llamadas a
Por ejemplo, es siempre mejor utilizar
10. Usar
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:
Pero imaginemos que actualizamos el método de la siguiente manera:
Si quisiéramos capturar las excepciones lanzadas en cualquier momento durante esa tarea, necesitaríamos de nuevo la palabra clave
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:
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 claveasync
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:
- Artículo original: https://markheath.net/post/async-antipatterns
- Imagen de portada: Marco Verch
2 Comentarios:
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.
Muchas gracias, me alegro de que te resulte útil!
Saludos!
Enviar un nuevo comentario