Métodos asíncronos en c#

Desde hace tiempo me ronda en la cabeza escribir algo acerca de la programación asíncrona.  ¿Quién no ha desarrollado aplicaciones que se  quedaban colgadas/ congeladas mientras que se estaba ejecutando una acción pesada? 

Un ordenador tiene un número determinado de procesadores y cada uno de ellos ejecuta únicamente una operación a la vez. Los métodos síncronos son bloqueantes debido a que el hilo que llama al método no hace otro trabajo hasta que el método se completa, por lo que para evitar lo que en muchos casos es un cuello de botella aparecen los métodos asíncronos.

En la versión 4 del framework de .NET se introdujo el espacio de nombres System.Threading.Tasks como un nuevo acercamiento a la programación asíncrona, dónde la clase  System.Threading.Tasks.Task<TResult> representa una tarea que se completará en un futuro. El aspecto de nuestros métodos asíncronos es de esta forma.

        Task<int> task = DosSegundosAsync();

        var awaiter = task.GetAwaiter();
        awaiter.OnCompleted(() =>
        {
            int resultado = awaiter.GetResult();
        });

El objeto Task<TResult> devuelto expone un método GetAwaiter que devuelve un TaskAwaiter<TResult> que sirve para poder anexar un delegado que se ejecutará cuando finalice la tarea .

Desde el la versión 4.5 del framework de .NET tenemos disponibles dos nuevos términos (keywords) para poder llevar a cabo la programación asíncrona de una forma más sencilla.

  • async. Es un modificador para los métodos que indica que su ejecución se puede llevar a cabo después de que el método que haga la llamada haya terminado.
    Un método asíncrono puede devolver void, Task o el genérico de este último Task<TResult>.
  • await.  Una expression await solo es posible en el contexto (keyword contextual) de un método marcado con el modificador async. El método corre de forma síncrona hasta que se encuentra con la primera expresión await, momento en el cual la ejecución se suspende hasta que la tarea esperada se complete.

Este nuevo escenario también es conocido como TAP (short for Task-based Asynchronous Pattern) y su “patrón” de uso es:

var result = await expression;
statement(s);

El compilador lo traduce como:

var awaiter = expression.GetAwaiter();
awaiter.OnCompleted (() =>
{
   var result = awaiter.GetResult();
   statement(s);
);

Escenario inicial

Dejamos de lado la teoría e intentemos plantear un escenario “real” dónde veamos que ventajas nos ofrece la programación asíncrona. Imaginemos una aplicación de consola que ejecuta un método con dos operaciones absolutamente bloqueantes (Thread.Sleep de dos y de tres segundos). Si mientras las operaciones están en proceso intentamos pular la tecla Intro para salir de la aplicación observamos que no hace nada hasta que finaliza dichos procesos.

    static void Main()
    {
        ControlTiempo.Inicio = DateTime.Now;
        Console.WriteLine(ControlTiempo.VerInicio());

        var operaciones = new Operaciones();
        operaciones.Ejecutar();

        Console.WriteLine("Pulse la tecla intro para salir");
        Console.ReadLine();
    }

    class Operaciones
    {
        public int Ejecutar()
        {
            var dosSegundos = DosSegundos();
            var tresSegundos = TresSegundos();
            var suma = dosSegundos + tresSegundos;

            Console.WriteLine("Resultado de la operación (Ejecutar): " + suma);

            ControlTiempo.Fin = DateTime.Now;
            Console.WriteLine(ControlTiempo.VerFin());
            return suma;
        }

        int DosSegundos()
        {
            const int millisecondsTimeout = 2000;
            Thread.Sleep(millisecondsTimeout);
            Console.WriteLine("ThreadId (DosSegundos): " + Thread.CurrentThread.ManagedThreadId);

            return millisecondsTimeout / 1000;
        }

        int TresSegundos()
        {
            const int millisecondsTimeout = 3000;
            Thread.Sleep(millisecondsTimeout);
            Console.WriteLine("ThreadId (TresSegundos): " + Thread.CurrentThread.ManagedThreadId);

            return millisecondsTimeout / 1000;
        }       
    }

El resultado de este proceso es el siguiente:

21/05/2014 16:36:45. Thread inicial: 9

ThreadId (DosSegundos): 9
ThreadId (TresSegundos): 9
Resultado de la operación (Ejecutar): 5

21/05/2014 16:36:50. Thread final: 9. Duración: 5
Pulse la tecla intro para salir

Todo se ejecuta en el mismo Thread (9), el resultado de la operación que es la suma de los segundos de una (2) y otra operación (3) es igual a 5 y la duración del proceso en segundos ha sido algo superior a esos 5 segundos.  Si hemos intentado escribir cualquier tecla o pulsar Intro para salir de la aplicación vemos que no ha hecho nada hasta que han terminado las operaciones.

Primera solución asíncrona

Vamos a crear dentro de la misma clase Operaciones, los métodos asíncronos para estos dos métodos (DosSegundos y TresSegundos). Los aspectos que debemos tener en cuenta son:

  • El valor devuelto por el la función asíncrona es del tipo Task<TResult>, Task o void.
  • Task.Run. Pone en cola el trabajo especificado para ejecutarlo en el ThreadPool y devuelve un controlador Task<TResult> para dicho trabajo.
  • La nomenglatura de un método asíncrono suele ser el nombre del método síncrono acompañado de la palabra Async.
        Task<int> DosSegundosAsync()
        {
            Console.WriteLine("DosSegundosAsync antes de await: " + Thread.CurrentThread.ManagedThreadId);
            var value = Task.Run(() => DosSegundos());
            Console.WriteLine("DosSegundosAsync después de await: " + Thread.CurrentThread.ManagedThreadId);

            return value;
        }

        Task<int> TresSegundosAsync()
        {
            Console.WriteLine("TresSegundosAsync antes de await: " + Thread.CurrentThread.ManagedThreadId);
            var value = Task.Run(() => TresSegundos());
            Console.WriteLine("TresSegundosAsync después de await: " + Thread.CurrentThread.ManagedThreadId);

            return value;
        }

También crearemos un nuevo método EjecutarAsync.

        public async void EjecutarAsync()
        {
            var dos = await DosSegundosAsync();
            var tres = await TresSegundosAsync();

            var suma = dos + tres;

            Console.WriteLine("Resultado de la operación (EjecutarAsync): " + suma);
            ControlTiempo.Fin = DateTime.Now;
            Console.WriteLine(ControlTiempo.VerFin());
        }

El resultado de este proceso sería el siguiente:

21/05/2014 16:39:36. Thread inicial: 9

DosSegundosAsync antes de await: 9
Pulse la tecla intro para salir

ThreadId (DosSegundos): 6
DosSegundosAsync después de await: 6
TresSegundosAsync antes de await: 6
ThreadId (TresSegundos): 10
TresSegundosAsync después de await: 10
Resultado de la operación (EjecutarAsync): 5

21/05/2014 16:39:41. Thread final: 10. Duración: 5

La aplicación de consola se ejecuta en el Thread (9) mientras que las operaciones se ejecutan en el Thread(6) y en el Thread(10), el resultado de la operación que es la suma de los segundos de una (2) y otra operación (3) es igual a 5 y la duración del proceso en segundos ha sido algo superior a esos 5 segundos.

En resumidas cuentas han cambiado dos cosas:

  • Si pulsamos cualquier tecla o la tecla Intro durante la ejecución vemos que la aplicación no se encuentra colgada ni congelada. Interesante ¿Verdad?
  • El thread dónde se ejecutan las operaciones respecto al que se ejecuta la aplicación de consola son distintos pero la duración del proceso sigue siendo la misma (algo más de 5 segundos). ¿Es posible bajar este tiempo?

Segunda solución asíncrona

Ahora la idea es que ambas operaciones (DosSegundosAsync y TresSegundosAsync) en vez de tener un await individual lo tengan común, para ello utilizamos el método WhenAll de la clase Task y de esa forma las dos operaciones se ejecutarán en paralelo.

        public async void EjecutarV2Async()
        {
            var dos = DosSegundosAsync();
            var tres = TresSegundosAsync();

            int[] ints = await Task.WhenAll(dos, tres);

            var suma = ints.Sum();

            Console.WriteLine("Resultado de la operación (EjecutarV2Async): " + suma);
            ControlTiempo.Fin = DateTime.Now;
            Console.WriteLine(ControlTiempo.VerFin());
        }

El resultado de este proceso sería el siguiente:

21/05/2014 16:56:58. Thread inicial: 9

DosSegundosAsync antes de await: 9
TresSegundosAsync antes de await: 9
Pulse la tecla intro para salir
ThreadId (DosSegundos): 10
DosSegundosAsync después de await: 10
ThreadId (TresSegundos): 11
TresSegundosAsync después de await: 11
Resultado de la operación (EjecutarV2Async): 5

21/05/2014 16:57:01. Thread final: 11. Duración: 3

La aplicación de consola se ejecuta en el Thread (9) mientras que las operaciones se ejecutan en los Threads 9, 10 y 11. El resultado de la operación que es la suma de los segundos de una (2) y otra operación (3) es igual a 5 y la duración del proceso en segundos ha sido ahora algo superior a 3 segundos.

¿Cómo puede ser? ¿Cómo es posible que el proceso total tarde un poco más que la operación que más tarda? La aplicación de consola  se han ejecutado en el Thread 9 y las dos operaciones se han ejecutado en pararelo, la operación DosSegundos en el Thread 10 y la operación TresSegundos  en el Thread 11.

El código completo de todo lo visto en este ejemplo lo tienes disponible en GitHub.

Agradecer a panicoenlaxbox su aporte y conocimiento para completar este post.

Este post es un resumen de este artículo de la MSDN.

twitter Métodos asíncronos en c#9google Métodos asíncronos en c#1facebook Métodos asíncronos en c#2linkedin Métodos asíncronos en c#8

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos necesarios están marcados *

Puedes usar las siguientes etiquetas y atributos HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>