martes, 23 de agosto de 2016

Matices en la programacion asíncrona en .NET

Me han pedido de forma personal que explique ciertos matices con respecto a la programación asíncrona en .NET que pueden no estar del todo claros para algunos interesados en el tema. Voy a poner un poco de código al que iremos evolucionando y analizando sus resultados.



Empecemos con un sencillo ejemplo:
public partial class Form1 : Form
{
  
  public Form1()
  {
    InitializeComponent();
  }
 
   async Task run()
  {
    Console.WriteLine($"Run Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    Stopwatch sw = new Stopwatch();
    sw.Restart();
    var t1 = DoWork(sw);
    var t2 = DoWork(sw);
    var t3 = DoWork(sw);
    var t4 = DoWork(sw);
    var t5 = DoWork(sw);
    await Task.WhenAll(t1t2t3,t4,t5);
    Console.WriteLine($"Run Thread End: {Thread.CurrentThread.ManagedThreadId}");
    sw.Stop();
    Console.WriteLine($"Elapsed {sw.ElapsedMilliseconds}");
 
  }
 
   async Task DoWork(Stopwatch sw)
  {
    Console.WriteLine($"DoWork Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Delay(5000);
    Console.WriteLine($"DoWork Thread End: {Thread.CurrentThread.ManagedThreadId}");
  }
 
  private void button1_Click(object senderEventArgs e)
  {
    Console.WriteLine($"Event Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    run();
    Console.WriteLine($"Event Thread End: {Thread.CurrentThread.ManagedThreadId}");
  }
}

Cuya salida es la siguiente:

Event Thread Start: 10 //no espera
  Run Thread Start: 10 //espera hasta que terminen las tareas asíncronas
    DoWork Thread Start: 10 //empiezan las tareas asíncronas
    DoWork Thread Start: 10
    DoWork Thread Start: 10
    DoWork Thread Start: 10
    DoWork Thread Start: 10
Event Thread End: 10 //no espera

    DoWork Thread End: 10  //tareas asíncronas terminan
    DoWork Thread End: 10
    DoWork Thread End: 10
    DoWork Thread End: 10
    DoWork Thread End: 10
  Run Thread End: 10 //esperaba a las tareas asíncronas

Elapsed 5013

Y su interpretación esta:

  • No hay ejecución multihilo. Todo el código administrado es ejecutado por un único hilo, el principal.
  • El código se ejecuta síncronamente hasta que llegamos a la tarea asíncrona (Task.Delay(5000)) y a partir de ahí el código administrado continúa sin esperar y se pueden ejecutar instrucciones mientras la tarea asíncrona termina.
  • Se lanzan y se ejecutan asíncronamente los 5 métodos DoWork por lo que al final la tarea tarda 5 segundos en lugar de (5*5) 25.
  • El hilo principal no se bloquea durante 25 (ni 5) segundos por lo que si es una app web podría atender otras peticiones o si es una app de escritorio la UI no se bloquearía.
Ahora agregamos algo de trabajo más pesado a nuestra función asíncrona:

public partial class Form1 : Form
{
 
  public Form1()
  {
    InitializeComponent();
  }
 
  async Task run()
  {
    Console.WriteLine($"Run Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    Stopwatch sw = new Stopwatch();
    sw.Restart();
    var t1 = DoWork(sw);
    var t2 = DoWork(sw);
    var t3 = DoWork(sw);
    var t4 = DoWork(sw);
    var t5 = DoWork(sw);
    await Task.WhenAll(t1t2t3t4t5);
    Console.WriteLine($"Run Thread End: {Thread.CurrentThread.ManagedThreadId}");
    sw.Stop();
    Console.WriteLine($"Elapsed {sw.ElapsedMilliseconds}");
 
  }
 
  async Task DoWork(Stopwatch sw)
  {
    Console.WriteLine($"DoWork Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Delay(5000);
    var start = sw.ElapsedMilliseconds;
    Console.WriteLine($"DoWork Enter While: {Thread.CurrentThread.ManagedThreadId}");
    while (sw.ElapsedMilliseconds < start + 1000) { }
    Console.WriteLine($"DoWork Thread End: {Thread.CurrentThread.ManagedThreadId}");
  }
 
  private void button1_Click(object senderEventArgs e)
  {
    Console.WriteLine($"Event Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    run();
    Console.WriteLine($"Event Thread End: {Thread.CurrentThread.ManagedThreadId}");
  }
}

Salida:

Event Thread Start: 10
  Run Thread Start: 10
    DoWork Thread Start: 10
    DoWork Thread Start: 10
    DoWork Thread Start: 10
    DoWork Thread Start: 10
    DoWork Thread Start: 10
Event Thread End: 10

    DoWork Enter While: 10
    DoWork Thread End: 10
    DoWork Enter While: 10
    DoWork Thread End: 10
    DoWork Enter While: 10
    DoWork Thread End: 10
    DoWork Enter While: 10
    DoWork Thread End: 10
    DoWork Enter While: 10
    DoWork Thread End: 10
  Run Thread End: 10
Elapsed 10013

Interpretación:
  • No hay ejecución multihilo. Todo el código administrado es ejecutado por un único hilo, el principal.
  • Se lanzan y se ejecutan asincronamente los 5 métodos DoWork pero el While se ejecuta en el hilo principal al terminar la tarea asíncrona.
  • El hilo principal SÍ se bloquea durante 5 segundos (uno por cada While). Por lo que el tiempo final son los 5s anteriores más los 5 de los While.
Y ahora digámosle al motor de ejecución que puede resumir la tarea en cualquier hilo en vez de continuar la ejecución en el hilo principal:

public partial class Form1 : Form
{
 
  public Form1()
  {
    InitializeComponent();
  }
 
  async Task run()
  {
    Console.WriteLine($"Run Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    Stopwatch sw = new Stopwatch();
    sw.Restart();
    var t1 = DoWork(sw);
    var t2 = DoWork(sw);
    var t3 = DoWork(sw);
    var t4 = DoWork(sw);
    var t5 = DoWork(sw);
    await Task.WhenAll(t1t2t3t4t5);
    Console.WriteLine($"Run Thread End: {Thread.CurrentThread.ManagedThreadId}");
    sw.Stop();
    Console.WriteLine($"Elapsed {sw.ElapsedMilliseconds}");
 
  }
 
  async Task DoWork(Stopwatch sw)
  {
    Console.WriteLine($"DoWork Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Delay(5000).ConfigureAwait(false);
    var start = sw.ElapsedMilliseconds;
    Console.WriteLine($"DoWork Enter While: {Thread.CurrentThread.ManagedThreadId}");
    while (sw.ElapsedMilliseconds < start + 1000) { }
    Console.WriteLine($"DoWork Thread End: {Thread.CurrentThread.ManagedThreadId}");
  }
 
  private void button1_Click(object senderEventArgs e)
  {
    Console.WriteLine($"Event Thread Start: {Thread.CurrentThread.ManagedThreadId}");
    run();
    Console.WriteLine($"Event Thread End: {Thread.CurrentThread.ManagedThreadId}");
  }
}

Salida:

Event Thread Start: 9
  Run Thread Start: 9
    DoWork Thread Start: 9
    DoWork Thread Start: 9
    DoWork Thread Start: 9
    DoWork Thread Start: 9
    DoWork Thread Start: 9
Event Thread End: 9

    DoWork Enter While: 11
    DoWork Enter While: 12
    DoWork Enter While: 14
    DoWork Enter While: 13
    DoWork Thread End: 11
    DoWork Enter While: 11
    DoWork Thread End: 12
    DoWork Thread End: 14
    DoWork Thread End: 13
    DoWork Thread End: 11
  Run Thread End: 9

Elapsed 7004

Interpretación:
  • ¡Tenemos ejecución multihilo en paralelo! Cada While se ejecuta en un hilo diferente y hasta se reutilizan en caso de poder hacerlo.
  • El hilo principal NO se bloquea.
  • El tiempo de ejecución (7s) es menor gracias a la ejecución multihilo en paralelo.
Notas finales:

Cuando nuestro código se resume en otro contexto de ejecución debemos tener en cuenta que no tenemos acceso al contexto del hilo principal. En una aplicación de escritorio no es mucho problema puesto que siempre le podemos pasar un delegado a un control para que lo invoque en su propio hilo pero en una app web se nos perdería el contexto HTTP y eso es bastante mas delicado.

4 comentarios:

  1. La verdad es que el sentido común me dice que lo lógico sería que el configureawait estuviese por defecto a false... sino para que quieres procesar las cosas en threads?

    ResponderEliminar
    Respuestas
    1. Recuerda que esto es asincronia, no paralelismo. Esta mas enfocado en evitar bloqueos que procesar cosas en background.

      Eliminar
    2. La verdad es que vi threads y no se me ocurrió que el concepto de asincronía no implica paralelismo.

      El caso es que me cuesta imaginarme partes del código que van a hacer un trabajo 'que te da igual cuando se haga'... aunque seguro que hay ocasiones así

      ¿Has usado alguna vez la asincronía sin paralelismo para alguna cosa?

      Eliminar
    3. Constantemente! En cualquier app web con .NET o Node.js cuando ejecutas una tarea asincrona (I/O es lo mas habitual) el hilo del servidor queda libre para ir aceptando otro request.

      Esto es asincronia.
      |----A-----|
          |-----B-------|
              |-------C------|

      Y esto es paralelismo:

      |-----A-----|
      |-----B-----|
      |-----C-----|

      Aunque la verdadera discursion seria concurrencia vs paralelismo pero como decía el narrador al final de Conan: “Eso ya es otra historia”.

      Eliminar