Hace algunos meses en un cliente me encontré con la necesidad de un proceso que necesitaba llamar varios sistemas externos para obtener la información de las entidades a tratar.
El escenario anteriormente mencionado o parecido es habitual en nuestra área de trabajo; procesos que necesitan obtener información de sistemas lentos: ya sea llamando a Web Services, accediendo a disco o accediendo a la red.
Normalmente nos enfrentamos a estos escenarios realizando algoritmos síncronos, algoritmos como el siguiente:
Tener los identificadores de los proveedores a procesar en lista
Para cada id:
{
Obtener la información del proveedor
Obtener las especialidades del proveedor con la información del proveedor
Crear un usuario con la descripción de especialidad que le corresponde
}
¿Qué hay de mal en este diseño? Pues, ¡primero las prestaciones!
Ejecutando este código nos damos cuentas que las operaciones más lentas, las que llaman BBDD y Web Services, se llevan la mayoría del tiempo de ejecución, pero el sistema en sí no tiene carga de trabajo ya que mientras la operación de llamada dura, el thread que está en ejecución espera a que la llamada finalice y devuelva el resultado.
Un primer enfoque sería utilizar los métodos async-await, métodos no bloqueantes de la versión 4.5 del .Net Framework para llamar a cada web service y liberar el thread, ya que la ejecución quedará parada en una puerta de IO y no lo necesita.
La construcción de los “tasks” y su ejecución, no es sencillo ya que algunos pasos necesitan el resultado de otros. Si pensamos en un caso extremos, si cada llamada necesita del resultado del punto anterior obtenemos simplemente una secuencia de llamadas en async-await a cada servicio.
En este punto, si nuestro proceso está hospedado en un entorno multi-thread, liberar los threads de esta ejecución será de ayuda y el sistema en su globalidad tendrá más throughput. Pero el proceso en sí no mejora los tiempos y aun peor, este enfoque empeorará los tiempos de respuestas.
La forma correcta de solucionar este problema, se llama Pipeline o Pipe and Filters[i], la cual divide el proceso en bloques operacionales (filters) y en objetos necesarios para pasar los datos de un bloque al otro (pipes). El patrón funciona de forma del todo parecida a un proceso químico, con los silos donde el proceso se activa y las tuberías que llevan los resultados intermedios a otros silos.
La solución pasa por crear bloques lógicos de ejecución para una entidad, cada paso se ejecuta y si no tiene todavía datos, se duerme. No se recorre ni se espera la total ejecución de un punto, solo la de una entidad a la vez antes de pasar el resultado a la cola sucesiva.
Tradicionalmente cada silo se ejecuta en un thread y estos se deben coordinar entre sí, pero en .Net tenemos ahora una forma más elegante de implementar este patrón y mucho más sencilla que entrar en las entrañas de la gestión del multi-threads: los Dataflow de la Task Parallel Library (TPL).
Los conceptos generales de la Dataflow library son exactamente la implementación del patrón Pipeline. Los actores principales son los ‘bloques’ (source blocks, target blocks, and propagator blocks). Para crear una pipeline es necesario concadenar simplemente estos bloques, la misma librería ya ofrece algunos bloques predefinidos: ActionBlock<T> y TransformBlok<I, O> por ejemplo calzan perfectamente con nuestro problema.
Lo primero que necesitamos es la pipe, o sea, un sitio donde se pone y recoge la información, dicho objetos debe ser thread-safe!
System.Collections.Concurrent es un namespace donde se pueden encontrar collaciones paralelas thread.safe ya lista para ser utilizadas!
Por lo tanto nos toca crear los bloques que representan la computación de cada paso:
Notar el uso de MaxDegreeOfParallelism, ¡este parámetro indica que esta parte del proceso se ejecutará de forma paralela! Los bloques de la TPL son multi-thread por defecto.
Después queda crear la pipeline misma, simplemente informando cada paso de cual sea el sucesivo.
Aunque parezca sencillo este código permite no solamente un multi-thread gestionado por la TPL gracias al grado de paralelismo, sino que, cuando el thread que está ejecutando un paso llega a un await, la gestión de la espera pasa a la puerta de IO y este thread se libera pudiendo ejecutar otro paso, de esta forma las CPUs siempre estarán computando y no gastando tiempo de espera.
¡Este pequeño cambio de enfoque puede transformar un proceso batch que dura unas cuantas horas en un proceso que tarda poco menos de media hora!
[i] https://en.wikipedia.org/wiki/Pipeline_(software)
Si quieres saber todo lo que puede hacer SOGETI bajo tecnología Microsoft, visita nuestra web.
. NET Senior Architect and Mobile Lead | Soluciones Microsoft | SOGETI ESPAÑA
0 comments on “Paralelismo, async-await y pipelines”