DNM+ Online
dotnetmania 2.0
Programación asíncrona en C# 5.0
¿Preocupado por cómo lidiar con la latencia de una operación remota? ¿Se enmaraña su código cuando no quiere esperar por la terminación de la invocación a un método del que se puede incluso no retornar? En este artículo echaremos un primer vistazo al nuevo recurso lingüístico async/await, que ya podemos probar en C# y VB mediante Microsoft Visual Studio Async CTP, así como al patrón propuesto para utilizar dicho recurso, que nos simplifica la forma de hacer programación asíncrona.

Conveniencia de la asincronía

Un gran salto en la productividad de la programación ocurrió a mediados del siglo pasado cuando se concibió e implementó el concepto de subrutina. A nuestros ancestros les parecía mágico que una secuencia de código podía interrumpir su ejecución e invocar a otra secuencia de código (subrutina) ubicada en otra parte y que tenía la capacidad de retornar al código a continuación del que hizo la invocación. Esto devino en la base para una gran abstracción: la separación del qué y el cómo; quien invocaba tenía que decidir qué quería, el invocado tenía que implementar cómo lograba hacer lo que le pedían. Esto favorecía la reutilización: el código invocado solo necesitaba escribirse una vez, y podría ser invocado más de una vez y desde diferentes lugares. Y también favorecía el mantenimiento, ya que cambiar el cómo se implementaba no tenía por qué implicar hacer cambios en quienes lo usaban.

Esto es fácil de entender y usar bajo un modelo secuencial y síncrono: el que invoca espera a que el invocado finalice de hacer lo que le han solicitado para continuar con su quehacer. Pero sabemos que, por lo general, el mundo real no funciona de esa manera: no nos sentamos a esperar por la pizza que ordenamos, sino que continuamos "picando" código hasta que el mensajero nos toca a la puerta.

Por qué esperar, si incluso tal vez no necesitemos para continuar de los resultados de lo invocado, o si no lo necesitamos de inmediato y queremos seguir haciendo algo mientras el invocado hace lo suyo. Los sistemas operativos actuales tienen estas capacidades de asincronía; note que nos parece natural que vayamos mirando un vídeo mientras descargamos una página web.

Usted seguro ha experimentado que no hay nada más estresante que una interfaz de usuario "congelada" porque se ha mandado a hacer algo que "demora" y nos quedamos bloqueados. ¿No se angustiaría si cuando manda a descargarse un archivo no se le mostrase una ventana como la de la figura 1?

Figura 1. Descargando un archivo

El asunto es cómo dotar a nuestras aplicaciones de una capacidad similar, cómo expresar que nuestro código continúe ejecutando mientras manda a hacer otra cosa, y cómo, dónde y cuándo utilizar después el resultado obtenido por lo invocado. Los lenguajes de programación no han incluido hasta ahora recursos suficientes para hacer fácil esta forma de programación. Note que esta forma asíncrona de trabajar puede ser útil haya o no un paralelismo real entre el accionar del invocado y el que invoca. El paralelismo puede ser físico si hay más de un procesador (o más de un ordenador en una red de ordenadores), o puede ser "virtual" si el tiempo de un procesador se distribuye en porciones entre varias hebras.

Es aquí precisamente donde está el principal aporte de esta Microsoft Visual Studio Async CTP [1], que se basa en un nuevo recurso lingüístico que se propone para C# y VB y un patrón para trabajar con él que pretende hacer que la programación asíncrona pueda ser similar y tan directa como la programación síncrona.

Asincronía usando explícitamente hebras

Trabajar con recursos remotos presenta situaciones diferentes a las locales. Las acciones "remotas" pueden tener mayor latencia, pueden fallar de nuevas maneras y de formas que no pueden evitarse, o simplemente pudieran no regresar. Es decir, que en general las acciones remotas pueden depender de factores que escapan al control del código que las invoca.

Aunque parezca que lo más sencillo sea interpretar las invocaciones remotas como simples llamadas a métodos, esto puede no ser lo más conveniente, porque en tal caso nos quedamos sin capacidad para manejar factores asociados a lo remoto como la cancelación, timeouts, excepciones, etc.

El código del listado 1 ordena un array de enteros utilizando el conocido algoritmo de ordenación por mezcla. Esto nos provoca la sensación de la ventana congelada (figura 2) hasta tanto se regrese de la llamada al método MergeSort.

private static void Merge(int[] A, int p, int q, int r, int[] interchange)
{
  // ...
}

public static void MergeSort(int[] A, int p, int r, int[] interchange)
{
  if (p < r)
  {
    var q = (int)Math.Floor((p + r) / 2.0);
    MergeSort(A, p, q, interchange);
    MergeSort(A, q + 1, r, interchange);
    Merge(A, p, q, r, interchange);
    Array.Copy(interchange, p, A, p, r - p + 1);
  }
}

static void Main(string[] args)
{
  Stopwatch crono = new Stopwatch();
  Console.Write("Entrar tamaño del array ");
  string s = Console.ReadLine();
  int n = int.Parse(s);      
  int[] a = CreaRandomArray(n);
  Console.WriteLine("\nOrdenando");
  crono.Restart();
  Sort.MergeSort(a, 0, a.Length - 1, new int[a.Length]);
  Console.WriteLine("Tiempo MergeSort síncrono {0} ms", crono.ElapsedMilliseconds);
}
Listado 1. Ordenación utilizando el método MergeSort invocado síncronamente
Figura 2. Ventana congelada esperando por la ordenación

Podríamos lograr la asincronía si creamos y echamos a ejecutar una hebra (listado 2) que haga la ordenación mientras nuestro programa se encarga de amenizar la ventana (figura 3). Pero esta solución ya no se logra de modo similar al caso síncrono, ya que el código debe ocuparse de crear la hebra y estar preguntando de modo específico por si esta sigue en ejecución.

static void Pointing()
{
  Thread.Sleep(200);
  Console.Write(".");
}

  // ...
  Console.WriteLine("\nOrdenando asíncronamente usando hebras");
  crono.Restart();
  Thread h = new Thread(() => {Sort.MergeSort(a, 0, a.Length - 1, new int[a.Length]);});
  h.Start();
  while (h.ThreadState == System.Threading.ThreadState.Running) 
    Pointing();
  Console.WriteLine("\nTiempo MergeSort asíncrono {0} ms", crono.ElapsedMilliseconds);
Listado 2. Utilizando hebras explícitamente para lograr la asincronía
figura 3. Ventana mientras se está ordenando

De todos modos, esto aún no es muy complicado de lograr, pero ¿qué pasa si queremos que el método asíncrono nos devuelva algún resultado? Considere por ejemplo que queremos obtener la mediana de los valores contenidos en el array (aquel valor que tiene en el array igual cantidad de valores mayores que él que de menores que él).

En este caso, tenemos que tener alguna forma de intercambiar información con la hebra. Esto se puede lograr pasándole a la hebra un objeto del contexto del que invoca (listado 3). Note como ya el código comienza a complicarse y ser más propenso a errores; una mala utilización del casting (Mediana)x provocaría una excepción en ejecución.

El nuevo enfoque con async

Con los nuevos recursos propuestos en Microsoft Visual Studio Async CTP, el código para calcular la mediana se escribiría de forma más simple como se muestra en el listado 4. Para que un método pueda ser invocado asíncronamente, éste debe cualificarse con el modificador async. Es una buena práctica (pero no es una exigencia del compilador) que el nombre del método termine con el sufijo Async; es por ello que nuestro método se llama MedianaAsync.

public static async Task<int> MedianaAsync(int[] a)
{
  await TaskEx.Run(() => { MergeSort(a, 0, a.Length - 1, new int[a.Length]); });
  return a[a.Length / 2];
} 

  // ...
  crono.Restart();
  var t = Sort.MedianaAsync(a);
  while (!t.IsCompleted) Pointing();
  Console.WriteLine("\nLa mediana es {0} calculada en {1} ms",
                    t.Result, crono.ElapsedMilliseconds);
Listado 4. Calculando la mediana usando async y await

La especificación async por sí sola no provoca la asincronía. Ésta debe combinarse con el uso dentro del método de una sentencia con el indicador await seguida del lanzamiento en ejecución de una tarea; entonces en este punto se retorna a quien invocó sin esperar por la terminación de dicha tarea, que ejecutará asíncronamente con el código que llamó al método async. Luego de terminar dicha tarea, se continúa en la sentencia que sigue al await, en este caso return a[a.Length / 2]. Note que esta instrucción ha retornado un int, cuando el tipo de retorno del método se ha indicado como Task; es el compilador el que se encarga de transformar el código para retornar realmente una tarea. El código que hizo la llamada es quien decide cuándo y dónde utiliza esta tarea (listado 4).

En general, todo método especificado async debe devolver un valor del tipo Task. En tal caso, del cuerpo del método se debe salir con una instrucción de la forma return expresión, donde la expresión debe ser de tipo T, y el compilador se encarga de generar el código para realmente formar una tarea de tipo Task. El código llamante puede utilizar este valor a través de la propiedad Result de la tarea, como se muestra en el listado 4.

¿Tareas o async?

El lector escéptico podrá argumentar que un código equivalente al del listado 4 se podría lograr usando directamente las tareas, como se muestra en el listado 5. A fin de cuentas, por lo que si las tareas ya fueron introducidas en .NET Framework 4.0 (ver artículos [3,4] de dNM), ¿para qué toda esta nueva parafernalia del async y el await?

// ...
  Task<int> t = new Task<int>( () => { 
                      Sort.MergeSort(a, 0, a.Length - 1, new int[a.Length]);
                      return a[a.Length / 2];
                    });
  t.Start();
  while (!t.IsCompleted) 
    Pointing();
  Console.WriteLine("\nLa mediana es {0} calculada en {1} ms",
                    t.Result, crono.ElapsedMilliseconds);
Listado 5. Calculando la mediana usando tareas

Pero lo cierto es que el código del listado 4 está más a tono con el objetivo de programar la asincronía de modo similar al modelo secuencial: invocamos al método asíncrono del mismo modo en que hasta ahora invocamos a un método, pero sin bloquearnos esperando a que éste termine. Quien llama determina qué es lo que quiere, y quien es llamado determina cómo expresa la asincronía y decidiendo cuándo regresar al que llamó sin esperar.

La valía de este nuevo recurso se entenderá mejor en un escenario de una interfaz gráfica de usuario (GUI), en la que por lo general queremos que las acciones no nos "congelen" la interfaz. Considere una interfaz gráfica como la que se muestra en la figura 4.

Figura 4. GUI para cálculo de la mediana

Quisiéramos que mientras se calcula la mediana se pueda seguir escribiendo en la ventana de texto. Si escribimos el código de la forma secuencial tradicional, como se muestra en el listado 6, entonces la ventana quedaría "congelada" mientras se está calculando la mediana.

public partial class Form1 : Form
{
  public Form1()
  {
    // ...
  }

  private int MedianaSync(int[] a)
  {
    MergeSort(a, 0, a.Length - 1, new int[a.Length]);
    return a[a.Length / 2];
  }
        
  // ...

  private void bCalculaMediana_Click(object sender, EventArgs e)
  {
    bCreaRandom.Enabled = false;
    tValorMediana.Text = MedianaSync(a).ToString();
    bCreaRandom.Enabled = true;
    bCalculaMediana.Enabled = false; 
  }
}
Listado 6. Cálculo de la mediana síncronamente

Si se usa async como se muestra en el listado 7, se invocaría asíncronamente al método MedianaAsync(a), de modo que no se bloquea la interacción con la interfaz; pero como la ejecución continúa inmediatamente en quién llamó, se estaría poniendo en la propiedad tValor­Mediana. Text un valor aún no calculado.

public partial class Form1 : Form
{
  public Form1()
  {
    // ...
  }

  int[] a;

  private void Merge(int[] A, int p, int q, int r, int[] interchange)
  {
    // ...
  }

  private void MergeSort(int[] A, int p, int r, int[] interchange)
  {
    // ...
  }

  private async Task MedianaAsync(int[] a)
  {
    await TaskEx.Run(() => { MergeSort(a, 0, a.Length - 1, new int[a.Length]); });
  }
        
  int[] CreaRandomArray(int size)
  {
    // ...
  }

  private void bCreaRandom_Click(object sender, EventArgs e)
  {
    // ...
  }

  private void bCalculaMediana_Click(object sender, EventArgs e)
  {
    bCreaRandom.Enabled = false;
    MedianaAsync(a);
    tValorMediana.Text = a[a.Length / 2].ToString();
    bCreaRandom.Enabled = true;
    bCalculaMediana.Enabled = false; 
  }
}
Listado 7. Cálculo de la mediana con asincronía errónea

El asunto aquí es dónde ubicar el código que queremos ejecutar (en este caso, mostrar en la interfaz el valor calculado de la mediana) luego de que termine lo que verdaderamente queremos se ejecute asíncronamente. El patrón a aplicar es ubicarlo a continuación del await, como se muestra en el listado 8. Este código es lo que se conoce como continuation y hasta ahora se expresaba con un callback, es decir, un delegado dónde indicamos lo que queremos que se ejecute luego de terminar lo que se ha pedido ejecutar asíncronamente. Ubicarlo a continuación de la sentencia con await significa ejecutarlo luego de que termine dicha sentencia mientras que el llamante continua su ejecución; ubicarlo a continuación de la invocación significa ejecutarlo sin esperar por la terminación del método llamado (lo que en este ejemplo daría un resultado indeseado).

public partial class Form1 : Form
{
  public Form1()
  {
    // ...
  }

  int[] a;

  private void Merge(int[] A, int p, int q, int r, int[] interchange)
  {
    // ...
  }

  private void MergeSort(int[] A, int p, int r, int[] interchange)
  {
    // ...
  }

  private async Task MedianaAsync(int[] a)
  {
    await TaskEx.Run(() => { MergeSort(a, 0, a.Length - 1, new int[a.Length]); });
    tValorMediana.Text = a[a.Length / 2].ToString();
    bCreaRandom.Enabled = true;
    bCalculaMediana.Enabled = false; 
  }
        
  int[] CreaRandomArray(int size)
  {
    // ...
  }

  private void bCreaRandom_Click(object sender, EventArgs e)
  {
    // ...
  }

  private void bCalculaMediana_Click(object sender, EventArgs e)
  {
    bCreaRandom.Enabled = false;
    MedianaAsync(a);
  }
}
Listado 8. Cálculo de la mediana con asincronía correcta

Async y las interfaces de usuario

Si el lector ha desarrollado alguna GUI, habrá tenido que lidiar con la engorrosa labor de actualizar los estados de los controles desde hebras que no son la hebra principal de la aplicación. El método Invoke de Windows Forms y el tipo Dispatcher en WPF y Silverlight son las soluciones propuestas para ello. La figura 5 muestra un código que realiza una llamada asíncrona, al finalizar la cual se desea cambiar algunos estados de un botón. Como la acción manejada por el delegado AsyncCallback se ejecuta en una hebra diferente a la que realizó la invocación, se produce una excepción preventiva que Visual Studio lanza en tiempo de depuración cuando se quiere acceder a las funcionalidades de los controles que no son seguros en contextos de múltiples hebras (thread-safe).

figura 5. Ejemplo de código con una mala práctica a la hora de acceder a funcionalidades de los controles que no son thread-safe

El listado 9 muestra un ejemplo de uso del método Invoke (definido en el tipo Control y por tanto aplicable a un botón) con el cual se puede actualizar de forma segura las propiedades de un botón.

  continueButton.Invoke(new Action(() =>
  {
    continueButton.Enabled = true;
    continueButton.Text = "Next";
  }));
Listado 9. Cambiando propiedades de un botón de forma thread-safe.

Ahora estos entuertos pueden obviarse con el uso de async. El código dentro de un método async que no esté en una sentencia await ejecuta en la misma hebra que el código desde el que se hizo la llamada al método async. Esa es la razón por la que en el listado 8 dentro del método MedianaAsync se puede acceder a las propiedades Text y Enabled de los controles de forma segura.

El listado 9 muestra la implementación de un DownloadManager el cual hace la descarga de un archivo de vídeo (copiando el archivo a una carpeta local) y lanza un evento para notificar que se concluyó la descarga. En el listado hemos resaltado las llamadas que se ejecutan de forma asíncrona en otra hebra; el resto se ejecuta en la hebra que efectuó la llamada.

public class DownloadManager
{
  public event Action<string> DownloadStarted;
  public event Action<string> DownloadCompleted;

  public async Task DownloadAsync(string uri)
  {
    DownloadStarted(uri);

    WebClient client = new WebClient();
    byte[] data = await client.DownloadDataTaskAsync(uri);

    string localName = System.IO.Path.GetFileName(uri);
    FileStream file = new FileStream(localName, FileMode.Create);
    await file.WriteAsync(data, 0, data.Length);
    file.Close();

    DownloadCompleted(localName);
  }
}
Listado 10. Definición del tipo DownloadManager para efectuar descargas de forma asíncrona.

Observe que al ejecutarse el await se retorna a quien hizo la llamada sin esperar porque la tarea del await termine. Note que en el primer await se ha invocado al método DownloadDataTaskAsync para descargar el archivo, y en el segundo await se ha invocado al método WriteAsync. Estas son versiones asíncronas de los métodos DownloadData y Write. Al disparar el evento DownloadCompleted , se ejecuta el manipulador del mismo (listado 11) en la hebra desde la que se hizo la llamada mientras ésta intenta ir descargando otros vídeos.

El listado 11 muestra un fragmento del código para una interfaz de usuario (en este caso desarrollada en WPF). Se está usando aquí el tipo VideoShow, que es un UserControl que implementamos en WPF (y que también trabaja de modo asíncrono), el cual visualiza un vídeo y permite manejar su reproducción.

Por razones de espacio, no se ha incluido aquí el código completo (el lector puede descargarlo de dNM+). Se supone que esta interfaz también permite indicar las direcciones de dónde descargar los vídeos, que aquí representamos mediante la colección denominada toDownLoad.

  // ...
  manager = new DownloadManager();

  manager.DownloadStarted += (fileName) =>
  {
    // No hay problemas en acceder a esta propiedad del control,
    // porque este método se ejecutará en la hebra principal.
    downloading.Content = "Downloading ... " + fileName;
  };

  manager.DownloadCompleted += (fileName) =>
  {
    downloading.Content = "Downloaded to ... " + fileName;

    VideoShow newVideo = new VideoShow
    {
        Width = 200,
        Height = 200,
        Margin = new Thickness(10),
        Video = new Uri(fileName, UriKind.RelativeOrAbsolute)
    };
                
    videos.Children.Add(newVideo);
  };

  foreach (var uri in toDownload)
    manager.DownloadAsync(uri);
Listado 11. Utilización del tipo DownloadManager desde el constructor de una aplicación de WPF.

La figura 6 muestra la ejecución de esta aplicación, en la que en la medida en que se van descargando los vídeos, usted puede interactuar con la interfaz e ir visualizando los vídeos sin que ésta se “congele".

Figura 6. Ejemplo de GUI utilizando async para realizar las descargas de los vídeos de forma asíncrona.

Conclusiones

Async será incorporado en la próxima versión de los lenguajes C# y VB y que ya se puede probar gracias a la CTP. Realmente, esta nueva característica requiere un cambio de perspectiva en el desarrollo de software al tratar de aplicar la máxima de "definir de forma asíncrona todo lo que pueda ser asíncrono", lo cual los desarrolladores deberán convertir en una práctica común.

Por ahora la CTP brinda, mediante el tipo AsyncCtpExtensions, versiones asíncronas (a manera de métodos extensores) de algunas funcionalidades de tipos ya existentes que son susceptibles a usarse asíncronamente por su latencia y por estar sujeto a situaciones no prevenibles. Tal es el caso de algunos métodos de los tipos Stream, WebClient, TcpClient y TextWriter y de los métodos DownloadDataTaskAsync y WriteAsync que usamos en el listado 10.

Este último ejemplo de visualización de vídeos aplica paralelismo aprovechando la existencia de más de un núcleo (pruebe a ejecutar el código sobre distintos CPU). Sin embargo, esto ocurre aquí porque es WPF quien se aprovecha de la existencia del paralelismo. Async y paralelismo se pueden combinar, pero no habido espacio ahora para tratar esto; por lo que posiblemente sea tema de un futuro artículo.

blog comments powered by Disqus
autores
referencias