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/es-es/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 (Este artículo)
2. Utilizar ConcurrentBag para almacenar el resultado de métodos asíncronos
3. Programación paralela en C# con la clase Parallel
Recientemente tuve que colaborar con un compañero de trabajo porque la ejecución de un proceso era más lenta de lo esperado. Repasando el código, vimos que parte del proceso se podía realizar en paralelo, con lo que el rendimiento general mejoró en un 200%. En este post intento explicar algunas técnicas básicas para trabajar con paralelización de procesos y mostraré las mejoras de rendimiento que se pueden obtener.
Caso inicial
Vamos a utilizar una simple aplicación de consola C# para ilustrar un caso sencillo, y cómo vamos a mejorar su rendimiento utilizando técnicas de computación paralalela. El código fuente lo podéis encontrar aquí: https://github.com/sgisbert/parallelization
El código inicial será el siguiente (Ver en Github):
using System.Threading;
using System.Diagnostics;
using System;
namespace parallel
{
class Program
{
static void Main(string[] args)
{
Stopwatch timer = new Stopwatch();
timer.Start();
for (int i = 0; i < 10; i++)
{
Process(i);
}
Console.WriteLine($"Completed: {timer.Elapsed}");
}
private static void Process(int id)
{
Stopwatch timer = new Stopwatch();
timer.Start();
Thread.Sleep(200);
Console.WriteLine($"Process {id}: {timer.Elapsed}");
}
}
}
Tenemos un proceso que tarda 200ms en ejecutarse, y lo ejecutamos 10 veces, de forma secuencial con un bucle for. Como cabría esperar, la ejecución de este código tarda unos 2 segundos en completarse, ya que cada proceso tiene que esperar a que termine el anterior para iniciarse:
Process 0: 00:00:00.2018134
Process 1: 00:00:00.2003564
Process 2: 00:00:00.2003647
Process 3: 00:00:00.2007673
Process 4: 00:00:00.2008702
Process 5: 00:00:00.2004851
Process 6: 00:00:00.2003682
Process 7: 00:00:00.2009726
Process 8: 00:00:00.2009657
Process 9: 00:00:00.2006482
Completed: 00:00:02.0362772
Convertir el método en asíncrono
El objetivo es que estos 10 procesos se puedan lanzar de manera asíncrona y en paralelo, de manera que no tengan que esperarse unos a otros para arrancar. El primer paso será convertir el método Process() en una función asíncrona, por lo que hacemos los siguientes cambios (ver archivo completo en Github):
- Cambiar la signatura del método:
private static async Task Process(int id)
- Cambiar la llamada:
await Process(i);
- Cambiar la signatura del método Main para que también sea asíncrono:
static async Task Main(string[] args)
Con esto ya tenemos nuestro método asíncrono, pero, ¿ha mejorado el rendimiento? Si lo volvemos a ejecutar, obtenemos exactamente los mismos valores que en la ejecución anterior, es decir, seguimos con el mismo problema.
Esto es debido a que estamos llamando al proceso con el modificador await, con lo que literalmente le estamos diciendo "espera a que termine para continuar". Es decir, estamos llamando a un método asíncrono de manera síncrona, y no es lo que queremos.
Ejecutar el proceso de manera asíncrona
Para ejecutar el proceso de manera asíncrona, debemos llamar al método sin el modificador await. En este caso, la función devuelve un objeto Task en lugar del resultado de la misma (aunque era void en este caso, pero podría devolver cualquier tipo de datos), y va a seguir la ejecución sin esperar a que ésta finalice. Pero igualmente necesitamos saber cuándo termina la ejecución de todos los procesos para seguir con la ejecución principal. Para ello, hacemos los siguientes cambios (ver archivo completo en Github):
- Creamos una lista de Task donde iremos guardando las Task que se vayan creando conforme vamos lanzando los procesos
List<Task> tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
tasks.Add(Process(i));
}
Task.WaitAll(tasks.ToArray());
- Esperamos a que todas las tareas terminen mediante Task.WaitAll()
Con estos cambios, volvemos a lanzar el programa y, ¡sorpresa! Seguimos teniendo el mismo rendimiento que al principio. ¿Qué ha pasado?
Esto es debido a que el compilador necesita de la combinación de async/await en un método para lanzar un nuevo hilo de ejecución. En este caso sencillo, la función Process() está declarada como async, pero no hace ninguna llamada await, por lo que el compilador la va a ejecutar de manera síncrona. El propio compilador nos avisará con un warning:
Si dentro de este método hiciéramos uso de otras llamadas asíncronas, como leer registros de una BD con EF Core, por ejemplo, entonces sí que tendríamos un nuevo hilo de ejecución y el paso siguiente no sería necesario.
Ejecutar el proceso en paralelo
Para asegurarnos de que el proceso se inicia en un hilo diferente en cada caso, modificamos el método de la siguiente manera (ver archivo completo en Github):
private static async Task Process(int id)
{
await Task.Run(() =>
{
Stopwatch timer = new Stopwatch();
timer.Start();
Thread.Sleep(200);
Console.WriteLine($"Process {id}: {timer.Elapsed}");
});
}
De esta manera, utilizamos await pero lanzando un nuevo hilo con Task.Run().
Cuando ejecutamos el proceso principal, estos son los resultados:
Process 2: 00:00:00.2016508
Process 3: 00:00:00.2010563
Process 1: 00:00:00.2016712
Process 0: 00:00:00.2053744
Process 7: 00:00:00.2008342
Process 6: 00:00:00.2008357
Process 5: 00:00:00.2008776
Process 4: 00:00:00.2009419
Process 8: 00:00:00.2091808
Process 9: 00:00:00.2091704
Completed: 00:00:00.6359204
Finalmente sí podemos apreciar una mejora notable, ya que hemos pasado de los 2 segundos iniciales a 0,6 segundos con la ejecución en paralelo. Notar igualmente que el orden en el que termina cada proceso es aleatorio, ya que ya no dependen del orden en el que se ejecutan, sino del tiempo que tarda cada uno de ellos.
Conclusiones
- Utilizando esta sencilla técnica de paralelización podemos aumentar drásticamente el rendimiento de nuestras aplicaciones.
- Hay que saber aplicarlo correctamente, porque simplemente convirtiendo un método en async y llamarlo con await no es suficiente, como hemos visto en el ejemplo. Usamos Task.WaitAll() para esto.
- En general, cuando tengamos procesos que no dependan unos de otros, podremos paralelizarlos, como por ejemplo, hacer una consulta a BD mientras cargamos unos textos de un archivo XML o llamamos a un servicio de una API externa.
- Si ves unas cuantas llamadas seguidas con await, piensa si se pueden paralelizar.
- Si un proceso dependo de otro, no se podrá paralelizar.
- No en todos los casos se puede paralelizar, por ejemplo, si usamos EF Core para el acceso a BD, no podemos tener dos llamadas simultáneas con el mismo contexto.
- En cambio, si trabajamos con archivos, podemos procesarlos de manera paralela y mejorar así el rendimiento.
En la siguiente entrada del blog comentamos cómo usar tipos de datos Thread-Safe para interactuar con ellos desde diferentes hilos, como por ejemplo, realizar procesos en paralelo que nos devuelvan el resultado en una misma Lista.