Asincronía en .NET 4.5: Async/Await
- Introducción
- Async/Await
- Task Parallel Library
- Introducción a Dataflow
Async/Await
La asincronía no es algo nuevo que haya inventado .NET 4.5, puesto que en versiones anteriores de .NET ya se podían realizar ejecuciones asíncronas (e incluso en otro tipo de Framework). Entonces, ¿qué tiene el Framework .NET 4.5 para que sea algo novedoso si estamos tratando de algo antiguo?
Antes de seguir, vamos a describir muy brevemente la diferencia entre una ejecución síncrona y una asíncrona. Cuando se realiza una ejecución síncrona, el que la ha invocado se queda esperando su respuesta. En el caso de una ejecución asíncrona, el que invoca esa ejecución continúa sin esperar respuesta. A grandes rasgos esa es la gran diferencia.
Vamos a volver a la novedad que aporta .NET 4.5 en este campo. La gran novedad es que esta versión simplifica enormemente la implementación de las ejecuciones asíncronas. Con tan sólo dos palabras reservadas (Async y Await) podemos convertir en asíncrono nuestro método.
- Async: Se añade en la declaración del método
- Await: Se añade en la invocación del método asíncrono
public static async void CopyToAsync(Stream source, Stream destination){ byte[] buffer = [0x1000]; int numRead; while ((numRead = await source.ReadAsync/buffer, 0, buffer.Lenght) >0) { await destination.WriteAsynx (buffer, 0, numRead); }}
Async
Antes hemos indicado que esta palabra reservada se incluye en la declaración del método, pero eso no implica que todo el código que tenga el método se asíncrono. Realmente lo que estamos haciendo es indicarle al compilador que en este método podemos tener ejecuciones asíncronas. Cuando se compile este método se van a buscar las invocaciones con la palabra reservada Await (hablaremos de ella un poco más tarde) para que sepa dónde tiene que parar la ejecución síncrona y dónde comienza la asíncrona. Así como de los medios que implementen el retorno de la función asíncrona cuando esta finalice.
Utilizando sólo Async y Await no estamos indicando ni manejando los hilos que se encargarán de esta ejecución, para nosotros será transparente. No obstante, si deseáis profundizar más sobre el tema, os recomiendo que leáis el artículo sobre Task Parallel Libary.
Si la asincronía es tan maravillosa y es tan fácil de implementar, ¿puedo hacer que todo mi código sea asíncrono? Existen unas limitaciones técnicas que veremos más adelante, pero en primer lugar debemos entender qué es la asincronía y revisar funcionalmente para saber qué parte de nuestro código tiene que ser asíncrono.
Volviendo a las limitaciones técnicas, podemos usar Async en cualquier parte excepto en estos casos:
- En el inicio de una aplicación (Main): en este caso no tiene sentido el que exista código asíncrono ya que cuando se llegue al método que esté marcado con el Await, se saldrá de la función y como en nuestro caso es el main, se terminará la ejecución de la aplicación.
- Métodos que tengan estos atributos:
-
- [MethodImpl(MethodImplOptions.Synchronized)]: Parece obvio que no tenga sentido que un método síncrono esté marcado como asíncrono así que no vamos a profundizar más en este caso
- [SecurityCritical] and [SecuritySafeCritical]: Los métodos con estos atributos implican que necesitan una respuesta inmediata, por lo que no tiene sentido que sean asíncronos ya que con un método asíncrono no esperamos respuesta.
Parámetros Ref y Out: En este caso no se pueden emplear porque no podemos asegurar el estado del objeto, ya que puede ser modificado en el código síncrono y asíncrono a la vez.
Await
Como hemos comentado antes, las invocaciones a funciones que contienen un Await se van a ejecutar asíncronamente. Lo que va a hacer es controlar el Suspend y el Resume de la función, para saber dónde tiene que continuar con la ejecución del hilo principal y de la función asíncrona.
Otra cosa que cambia cuando ponemos Await en la función es que el retorno de la función pasa a ser un objeto de tipo Task. Tanto el resultado de la función como las posibles excepciones que puedan ocurrir se van a incluir dentro de ese objeto Task.
Al igual que con el Async, no se puede poner Await en todas las invocaciones a las funciones, hay ciertas limitaciones técnicas:
- Si el método no es Async: Como hemos descrito antes la palabra reservada Async indica al compilador que busque las invocaciones Await, por lo que si no tenemos ese Async no se buscarían las invocaciones asíncronas así que no serviría de nada tenerlo marcado como Await
- Getters y Setters: Hay formas de hacer que estos métodos sean asíncronos, pero irían en contra de su filosofía, ya que se tratan de métodos que van a devolver resultados de forma rápida, por lo que no tiene sentido que sean asíncronos.
- lock/SyncLock block: Puede ser peligroso el tener un lock en un método asíncrono, ya que puede bloquear la ejecución síncrona, por lo que tampoco tendría sentido usarlo aquí.
- Catch/Finally: Teniendo en cuenta que se trata de controlar una excepción o de las instrucciones finales del método, el que se ejecute de forma asíncrona no hará otra cosa más que entorpecer la ejecución del método.
Como podéis ver las limitaciones que tienen tanto el Async como el Await no son tanto limitaciones técnicas como limitaciones basadas en el sentido común y en que se siga la filosofía de la programación asíncrona.
Antes y Ahora
Ya hemos dicho que la programación asíncrona no es un invento actual y que existía desde hace más tiempo, pero el gran cambio es que ahora se ha simplificado mucho. Os voy a poner un ejemplo en el que se ve de forma más clara. En el ejemplo veremos cómo se implementa la lectura y escritura en un fichero de forma asíncrona antes y después de .NET 4.5. En este ejemplo lo importante no es entender exactamente qué hace el código sino ver la diferente complejidad entre ambos ejemplos (los ejemplos que se muestran en Antes y Ahora están extraídos de este artículo https://blogs.msdn.com/b/dotnet/archive/2012/04/03/async-in-4-5-worth-the-await.aspx).
Antes
public static IAsyncResult BeginCopyTo(Stream source, Stream destination) { var tcs = new TaskCompletionSource(); byte[] buffer = new byte[0x1000]; Action<IAsyncResult> readWriteLoop = null; readWriteLoop = iar => { try { for (bool isRead = iar == null; ; isRead = !isRead) { switch (isRead) { case true: iar = source.BeginRead(buffer, 0, buffer.Length, readResult => { if (readResult.CompletedSynchronously) return; readWriteLoop(readResult); }, null); if (!iar.CompletedSynchronously) return; break; case false: int numRead = source.EndRead(iar); if (numRead == 0) { tcs.TrySetResult(true); return; } iar = destination.BeginWrite(buffer, 0, numRead, writeResult => { try { if (writeResult.CompletedSynchronously) return; destination.EndWrite(writeResult); readWriteLoop(null); } catch(Exception e) { tcs.TrySetException(e); } }, null); if (!iar.CompletedSynchronously) return; destination.EndWrite(iar); break; } } } } catch(Exception e) { tcs.TrySetException(e); } }; readWriteLoop(null); return tcs.Task; } public static void EndCopyTo(IAsyncResult asyncResult) { ((Task)asyncResult).Wait(); }
Ahora
public static async void CopyToAsync(Stream source, Stream destination) { byte[] buffer = new byte[0x1000]; int numRead; while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) { await destination.WriteAsync(buffer, 0, numRead); } }
Con esta representación tan gráfica podemos ver cómo se ha simplificado enormemente la codificación de la programación asíncrona se ha reducido a añadir dos palabras en el código. Es posible que luego el compilador escriba un código tan complejo como el que hemos visto antes, pero para el programador se vuelve una tarea mucho más sencilla.
¿Para qué sirve?
Si lo que buscamos es que nuestra aplicación sea más rápida, este no es el sistema que tenemos que emplear.
Con una sencilla aplicación que lee unos ficheros de forma síncrona y asíncrona y controla el tiempo que se emplea en cada lectura podemos ver que la lectura asíncrona no es más rápida que la lectura síncrona.
Este es el código que realiza la lectura del fichero
privateasyncvoid ReadAsync(object param) { PrepareTest(); Stopwatch sw = new Stopwatch(); sw.Start(); using (StreamReader sr = new StreamReader(fichero1, System.Text.ASCIIEncoding.ASCII)) { await sr.ReadToEndAsync(); sw.Stop(); sr.Close(); } ResultList.TryAdd(String.Format("Primera duración: {0} ms", sw.ElapsedMilliseconds)); sw.Restart(); using (StreamReader sr2 = new StreamReader(fichero2, System.Text.ASCIIEncoding.ASCII)) { await sr2.ReadToEndAsync(); sw.Stop(); sr2.Close(); } ResultList.TryAdd(String.Format("Segunda duración: {0} ms", sw.ElapsedMilliseconds)); FinishTest(); }
Y este es el resultado en pantalla:
Este es el código que lee los mismos ficheros de forma síncrona. Como podemos ver, la diferencia es que el método anterior estaba invocando a la función ReadToEndAsync mientras que la del método Sync invoca a ReadToEnd
privatevoid Read(object param) { PrepareTest(); Stopwatch sw = new Stopwatch(); sw.Start(); using (StreamReader sr = new StreamReader(fichero1, System.Text.ASCIIEncoding.ASCII)) { sr.ReadToEnd(); sw.Stop(); sr.Close(); } ResultList.TryAdd(String.Format("Primera duración: {0} ms", sw.ElapsedMilliseconds)); sw.Restart(); using (StreamReader sr2 = new StreamReader(fichero2, System.Text.ASCIIEncoding.ASCII)) { sr2.ReadToEnd(); sw.Stop(); sr2.Close(); } ResultList.TryAdd(String.Format("Segunda duración: {0} ms", sw.ElapsedMilliseconds)); FinishTest(); }
Como podemos ver, la lectura síncrona ha resultado ser mucho más rápida. Entonces, ¿para qué la podemos utilizar la lectura asíncrona? Siguiendo con nuestro ejemplo, cuando la aplicación está leyendo el fichero de forma asíncrona, la aplicación deja de responder, se queda “congelada”. Dependiendo del tipo de aplicaciones esto puede ser un grave inconveniente, por lo que usar la programación asíncrona puede solventarlo aunque sea un poco más lenta.
Usando el mismo ejemplo, vamos a realizar esa llamada de forma asíncrona y síncrona de los mismos ficheros mientras invocamos a una función de “Hello world”.
Este es el resultado de la llamada Asíncrona:
Y este es el resultado de la llamada síncrona:
Como podemos ver, aunque ha tardado menos, la ejecución síncrona no ha podido ejecutar el Hello World hasta que ha terminado la lectura del fichero, mientras que la ejecución asíncrona sí nos ha permitido realizar esas ejecuciones.
Podemos trasladar este ejemplo a casos más prácticos, como puede ser el permitir que nuestra aplicación continúe funcionando mientras se realizan operaciones pesadas.
Espero que os sirva de ayuda
- José Ortega Gutiérrez