Programación asíncrona con async/await en .Net
Introducción
A partir de la versión 4.5 de .Net framework se ha simplificado de forma considerable la forma en que podemos trabajar con código asíncrono. Con los anteriores frameworks, si queríamos contar con los beneficios de una programación asíncrona nos veíamos obligados a lidiar con una gran complejidad en nuestro código. Esto nos hacía evitar su uso en lo posible a pesar de perder esta importante característica.Stephen Cleary, un MVP especializado en concurrencia define la programación asíncrona como:
Una forma de concurrencia que utiliza futuros o "callbacks" para evitar hilos innecesarios.La programación moderna con async y await nos abstrae de la utilización de "callbacks" y nos permite utilizar futuros (Tasks) que se encargarán de notificar al llamante cuando el método asíncrono se complete.
async y await
Las palabras async y await son las palabras clave que .Net ha introducido en el lenguaje para que podamos implementar métodos asíncronos como si se tratase de métodos síncronos. El compilador realiza el trabajo difícil por nosotros permitiendo mantener una apariencia del código bastante simple.La palabra async simplemente marca a un método como asíncrono. A su vez, un método marcado como asíncrono estará habilitado para usar la palabra await y comenzar así una "espera".
Aunque el método sea asíncrono se inicia como cualquier método síncrono. Todo cambia cuando se encuentra la palabra await seguido de un argumento de tipo "awaitable". A partir de entonces comenzará a comportarse de forma asíncrona.
Aunque existen diversos tipos awaitables (incluso podemos implementar los nuestros), lo más habitual es usar el tipo tarea Task o Task<T> proporcionado por el framework.Dentro de nuestro método
DoWorkAsync
llamamos al método GetStringAsync
, que también es un método asíncrono puesto que devuelve una Tarea (en concreto Task<string>
) y en consecuencia se trata de un objeto "awaitable". Al ir precedido de await comenzará la parte asíncrona de nuestro método, es decir, se ha introducido una espera asíncrona. En consecuencia se detiene la ejecución del método DoWorkAsync
y se devuelve una tarea Task
sin completar. Esta tarea tendrá el compromiso de ejecutar el resto del método cuando se complete. Si un await se encuentra con una tarea que ya está completada se comportará como una operación síncrona.
Task vs Task<T>
En el ejemplo anterior nuestro método asíncrono devolvíaTask
. Esto quiere decir que no se espera ningún valor de retorno. Usaremos Task<T>
para métodos asíncronos que queramos que nuestro método asíncrono devuelva un valor. Veamos el mismo ejemplo anterior pero esta vez nuestro método asíncrono también tendrá el compromiso de generar un string
cuando se complete. Se debe evitar el uso de async void ya que void no es "awaitable". Recuerda que necesitas un "awaitable" para usarlo junto a await. Además, las posibles excepciones producidas en la operación asíncrona son capturadas dentro de la tarea. Cuando la tarea es "esperada" mediante await la excepción es relanzada y puede ser capturada mediante un try / catch. Si usamos void no tendremos oportunidad de capturar la excepción.
Varios awaits dentro de un método asíncrono
Es muy habitual encontrarnos varios awaits dentro de un método asíncrono. Cada vez que una operación asíncrona se haya completado, el flujo de ejecución retornará a terminar el código de nuestro método hasta que se termine o hasta que se encuentre otro await que inicie una nueva operación asíncrona. En el código anterior nuestro método detendrá la ejecución hasta en tres ocasiones devolviendo la tarea sin completar en cada una de ellas. Sólo al final podrá devolver la tarea completada con el resultado.async Task vs Task
Se puede optar por devolver directamente una tarea completada en lugar de hacer un método asíncrono. Esto puede resultar útil cuando tenemos que implementar una interfaz con métodosTask
pero nuestra implementación realmente no necesita dentro ningún await, es decir, no tiene por qué ser asíncrono. En ese caso podemos hacer los siguiente: El método GetMagicNumber
implementado es bastante simple y no necesita ningún await para obtener el resultado. Podemos usar Task.FromResult
que devuelve una tarea completada de forma síncrona con el valor que queramos. Así evitamos tener un método asíncrono haciéndolo mucho más eficiente y respetando la firma de nuestra interfaz. Beneficios que nos aporta la programación asíncrona
Cuando usamos la programación asíncrona conseguiremos, o bien responsividad si se trata de una aplicación con una interfaz de usuario gráfica, o bien escalabilidad si se trata de una aplicación de servidor. En el primer caso evitaremos que la interfaz quede bloqueada e inservible para el usuario durante la operación. En el segundo caso permitiremos que el hilo que está gestionando la petición actual quede libre para ser reutilizado por otra petición entrante, aumentando significativamente la escalabilidad de nuestro servidor.Antes de continuar deberíamos distinguir dos tipos de operaciones "costosas": operaciones CPU-Bound o "vinculadas a CPU" y operaciones I/O-Bound o "vinculadas a entrada y salida".
Operaciones CPU-Bound vs I/O-Bound
Las operaciones CPU-Bound son operaciones donde se requiere que la CPU realice numerosos cálculos, como por ejemplo el cálculo de factoriales, algoritmos de compresión, encriptación, etc. Este tipo de operaciones necesita forzosamente un hilo para realizar la operación ya que para llevar a cabo la operación la CPU ejecuta código.Las operaciones I/O-Bound son operaciones externas a la CPU donde delegamos a un subsistema de entrada o de salida una operación como por ejemplo acceso a disco, conexión a una base de datos, acceso a la red, etc. En este tipo de operaciones no se requieren hilos mientras se llevan a cabo. A diferencia del caso anterior nuestra CPU no ejecuta código durante la operación.
Ambos tipos de operaciones pueden producir cuellos de botella debido al retardo producido y en ambos tipos podemos mejorar nuestras aplicaciones utilizando la programación asíncrona.
Cuando el hilo de nuestra aplicación se encuentra con una operación demasiado larga vinculada a I/O podemos optar por esperar: bloqueo; o seguir con otra cosa mientras el proceso termina: programación asíncrona. En este caso el hilo que inició la operación es liberado para realizar otra operación. Una programación asíncrona nos permitirá obtener responsividad en el caso de que estemos en una aplicación de interfaz de usuario y escalabilidad si estamos en una aplicación de servidor. Para operaciones vinculadas a entrada y salida usaremos async y await tal y como se ha explicado anteriormente.
Sin embargo, para operaciones vinculadas a CPU deberemos tener presentes algunas consideraciones. En este caso no se trata de una operación asíncrona "pura", si no que necesitamos ejecutar código de forma síncrona. Eso sí, podemos aportar responsividad al delegar a otro hilo esta tarea liberando al hilo de nuestra interfaz. Para ello haremos uso del método
Task.Run()
que solicita un hilo del thread pool para que se encargue de ejecutar nuestra operación. Programación asíncrona con Task.Run para operaciones CPU-Bound
En el caso de las operaciones vinculadas a la CPU podemos encontrarnos con un problema si nuestro hilo de ejecución es el hilo de una interfaz de usuario: pérdida de la responsividad.
Necesitamos un hilo que se encargue de la operación, sí, pero no queremos que nuestro hilo principal de la interfaz de usuario se ponga a hacer trabajos pesados que produzcan un bloqueo de la interfaz desagradable para el usuario. Para evitarlo debemos utilizar
Task.Run
para ejecutar nuestro código de una forma asíncrona. Task.Run
ejecutará nuestro código en un hilo del "thread pool" y liberará el hilo de la interfaz de usuario para evitar su bloqueo. Ambos hilos se ejecutarán de forma paralela.Task.Run
devuelve una tarea que al esperarla (await) estaremos liberando el hilo de la interfaz de usuario. Uso de Task.Run fuera de una interaz de usuario
El uso deTask.Run
fuera del contexto de una interfaz de usuario no tiene ningún beneficio, sólo producirá un descenso en el rendimiento. Si no tenemos la necesidad de "liberar" una interfaz de usuario lo mejor es que sea un único hilo quien gestione toda la operación de inicio a fin. Veamos un ejemplo correcto de una aplicación de servidor ASP.NET en la que llamamos a una operación vinculada a CPU desde un método de acción de un controlador: En este ejemplo la petición realiza todo el proceso de forma síncrona. Un único hilo es el que se encarga de todo el proceso. Sin embargo, veamos ahora un mal ejemplo en el que haremos uso de Task.Run
en el mismo escenario. Aunque el ejemplo funciona perfectamente se produce un intercambio de hilos innecesario reduciendo la escalabilidad de la aplicación. El hilo que inicia el método de acción es el hilo ASP.NET. Cuando se ejecuta Task.Run
se está requiriendo un hilo extra a nuestro thread pool para que se encargue de la operación. A su vez, el hilo original es devuelto al thread pool para que pueda atender nuevas peticiones. Cuando la tarea termina, el hilo extra es devuelto al thread pool. Esta reducción del rendimiento es menos significativa cuanto mayor sea el tiempo empleado en la operación. Convención de firma Async para operaciones CPU-Bound
Una mala práctica es "ocultar" tras un método con una firma del tipo MyMethodAsync unTask.Run
que ejecuta un método vinculado a CPU. El hecho de devolver una Task, al ser "awaitable", invitará al consumidor a usar una estructura async/await pensando que el método es realmente asíncrono cuando realmente no lo es. Aunque liberará el hilo actual, otro hilo se quedará efectuando una tarea de forma síncrona, algo que en la mayoría de casos puede no ser importante pero que en otros podría causar efectos no esperados para el desarrollador. Además, una aplicación ASP.NET, creyendo que está mejorando la escalabilidad de la aplicación al hacer uso de async y await, en realidad la estaría empeorando tal y como se ha explicado en el apartado anterior.Como resumen podríamos decir que se deberían dejar los métodos vinculados a CPU como síncronos (sobre todo en código que forme parte de bibliotecas reutilizables), y que los consumidores del método decidan si usar
Task.Run
en función del contexto en el que se encuentren. Una buena práctica es documentar el código de forma que indique que el método es CPU-Bound. En el ejemplo siguiente el método
DoWork
realiza una operación vinculada a la CPU. Un consumidor que quiera obtener responsividad en una aplicación de escritorio llamaría al método usando Task.Run
para liberar la interfaz, sin embargo, una aplicación de servidor o de consola preferirá llamar al método de forma síncrona:
Written on October 14, 2018