Disclaimer: los métodos y ejemplos de código de este artículo son fruto de mi propia investigación y aprendizaje autónomo, y en ningún caso están preparados para su utilización en código real de producción. El autor no se hace responsable del uso de estos ejemplos. Para más información sobre programación paralela y asíncrona y sus connotaciones, recomiendo acceder a la documentación oficial: https://docs.microsoft.com/dotnet/standard/parallel-processing-and-concurrency 

Este post forma parte de una serie de 3 artículos sobre mejoras de rendimiento utilizando programación paralela:

1. Mejorar el rendimiento con funciones asíncronas para la ejecución de procesos en paralelo
2. Utilizar ConcurrentBag para almacenar el resultado de métodos asíncronos
3. Programación paralela en C# con la clase Parallel (Este artículo)

 

Siguiendo con la serie de posts dedicados a la programación asíncrona y a la mejora de rendimiento utilizando Task para la paralelización de procesos, en este caso introducimos una nueva posibilidad utilizando la clase Parallel que nos ofrece .Net, y la comparamos con las ejecuciones anteriores, donde recordamos que se ejecutaba un proceso que nos devolvía una lista de 10 números aleatorios entre 1 y 100, y lo dejamos en unos 0,6s de tiempo de ejecución, frente a los 2s originales de un proceso síncrono:

Result: 82,83,88,99,57,71,13,54,18,40
Completed: 00:00:00.6276547

El código fuente de ejemplo mostrado lo podéis encontrar en Github: https://github.com/sgisbert/parallelization

En estos ejemplos, usábamos Task.Run() para ejecutar el proceso en un nuevo hilo. Es importante entender que el uso de Task para crear nuevos hilos de ejecución está limitado por el número de cores que tengamos en el procesador, y el uso excesivo de estos puede llevar a un uso poco eficiente de los recursos del sistema. Para entender más estos conceptos, recomiendo leer este artículo: https://www.pluralsight.com/guides/using-task-run-async-await#module-knowthenatureofyourasynchronousoperation

El uso de async/await es especialmente recomendable junto con librerías o APIs que ofrecen métodos asíncronos sin necesidad de acudir a Task.Run() para crearlos. Buenos ejemplos de este caso son HttpClientFileStream, que ofrecen métodos de I/O asíncronos donde se pueden aprovechar las virtudes de la programación asíncrona.

Si aún así tenemos un caso como el del ejemplo, donde tenemos que realizar un mismo procesamiento sobre múltiples instancias diferentes, podemos aprovechar la clase Parallel, y ver cómo se comporta en comparación con Task.WaitAll()Task.WhenAll().

 

Task.WaitAll() vs Task.WhenAll()

Existen dos maneras de esperar a que todas nuestras tareas terminen de ejecutarse:

await Task.WhenAll(tasks.ToArray());

Task.WaitAll(tasks.ToArray());

Y es interesante conocer la diferencia entre las dos. En términos de rendimiento, en ambos casos el proceso dura un tiempo similar: 0,6s, pero hay ciertos matices interesantes a conocer:

Task.WaitAll() 

  • Bloquea el thread principal de ejecución hasta que todas las tareas terminen, por lo que no devuelve una Task. Esto significa que si tenemos una aplicación con UI, éste quedará bloqueado hasta que termine la ejecución
  • Si se producen excepciones en una o varias tareas, lanzará una AggregateException con todas las excepciones que se hayan producido

await Task.WhenAll()

  • No bloquea el thread principal, por lo que sería el método recomendado para aplicación con UI y métodos async, ya que sí devuelve una Task.
  • Si se producen excepciones, sólo lanzará la primera que ocurra.

En este artículo se detalla con más nivel de detalle las diferencias entre ambos métodos: https://www.infoworld.com/article/3152735/when-to-use-taskwaitall-vs-taskwhenall-in-net.html

 

Utilizando la clase Parallel

Otra manera de aprovechar las ventajas de la computación paralela, es utilizar la clase Parallel de .Net (más info en la documentación oficial)

Lo primero que haremos con nuestro código de ejemplo será transformar nuestro proceso para que no necesite utilizar Task.Run(), pero manteniendo la compatibilidad con Task para comparar ambos resultados. Para ello, modificamos el método process de la siguiente manera (ver archivo completo en GitHub):

private static async Task Process(int id, ConcurrentBag<int> cb)
{
    await Task.Run(() =>
    {
        CoreProcess(id, cb);
    });
}

private static void CoreProcess(int id, ConcurrentBag<int> cb)
{
    Stopwatch timer = new Stopwatch();
    timer.Start();
    Random random = new Random();
    Thread.Sleep(200);

    int number = random.Next(1, 100);
    cb.Add(number);

    Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Process {id}: {timer.Elapsed}");
}

Hemos añadido también el valor de Thread.CurrentThread.ManagedThreadId a la salida por consola para comprobar los Threads que se crean.

De esta manera, podemos llamar a nuestro proceso tanto de manera asíncrona como síncrona.

Parallel.ForEach()

Añadimos el siguiente código, que utiliza Parallel.ForEach() para lanzar el proceso síncrono, pero usando paralelismo (ver archivo completo en GitHub):

timer.Restart();
ConcurrentBag<int> cb2 = new ConcurrentBag<int>();
var sourceCollection = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
Parallel.ForEach(sourceCollection, (id) =>
{
    CoreProcess(id, cb2);
});
Console.WriteLine($"Result: {string.Join(",", cb2)}");
Console.WriteLine($"Completed: {timer.Elapsed}");
Console.WriteLine();

Y comparamos los resultados:

Task.WaitAll()

[6] Process 2: 00:00:00.2028313
[5] Process 1: 00:00:00.2039001
[4] Process 0: 00:00:00.2063105
[7] Process 3: 00:00:00.2027921
[4] Process 5: 00:00:00.2006033
[7] Process 7: 00:00:00.2006397
[5] Process 6: 00:00:00.2006732
[6] Process 4: 00:00:00.2007073
[7] Process 8: 00:00:00.2001104
[5] Process 9: 00:00:00.2009985

Result: 15,49,52,76,90,1,71,67,37,23
Completed: 00:00:00.6398704

Parallel.ForEach()

[6] Process 9: 00:00:00.2011420
[5] Process 3: 00:00:00.2012138
[1] Process 1: 00:00:00.2013376
[4] Process 7: 00:00:00.2011324
[7] Process 5: 00:00:00.2011974
[1] Process 2: 00:00:00.2004285
[4] Process 8: 00:00:00.2004063
[7] Process 6: 00:00:00.2005255
[6] Process 10: 00:00:00.2006825
[5] Process 4: 00:00:00.2006454
Result: 50,82,38,39,37,96,80,48,95,90
Completed: 00:00:00.4507518

La tarea ejecutada con Parallel ha bajado el tiempo a 0,45s frente a los 0,6s de Task. Hemos obtenido una mejora de rendimiento sin tener que ejecutar Task.Run() que puede ocasionar problemas con el uso de los recursos disponibles.

Parallel.Invoke()

Parallel nos ofrece otra alternativa adicional para invocar tareas en paralelo, con el método Invoke(). Para probar su uso, añadimos las siguientes líneas de código (ver archivo completo en Github):

timer.Restart();
ConcurrentBag<int> cb3 = new ConcurrentBag<int>();
List<Action> actions = new List<Action>();
foreach (var i in sourceCollection)
{
    actions.Add(() => CoreProcess(i, cb3));
}
Parallel.Invoke(actions.ToArray());
Console.WriteLine($"Result: {string.Join(",", cb3)}");
Console.WriteLine($"Completed: {timer.Elapsed}");

Con los siguientes resultados:

[4] Process 3: 00:00:00.2008078
[6] Process 4: 00:00:00.2008435
[5] Process 5: 00:00:00.2007954
[7] Process 2: 00:00:00.2008161
[1] Process 1: 00:00:00.2009441
[7] Process 8: 00:00:00.2001254
[5] Process 6: 00:00:00.2001896
[4] Process 7: 00:00:00.2001298
[8] Process 10: 00:00:00.2033235
[6] Process 9: 00:00:00.2089627
Result: 55,26,74,29,32,45,71,46,39,90
Completed: 00:00:00.4121676

En este caso, Invoke() reduce el tiempo hasta casi 0,4s.

 

Conclusiones

  • Analiza bien cada situación, entendiendo cómo funcionan los métodos asíncronos y las diferencias entre Task.WaitAllTask.WhenAll, para utilizarlos correctamente en cada caso
  • Conoce otras alternativas como Parallel para mejorar el rendimiento de tus aplicaciones
  • No te creas los resultados de estos ejemplos. Haz pruebas de rendimiento por ti mismo y comprueba en cada caso qué te funciona mejor