Si deseamos que nuestro software siga beneficiándose de las capacidades futuras de hoy y mañana de los procesadores debemos empezar a cambiar nuestra manera de desarrollar. La razón de esto es que las aplicaciones de negocio van tener que ejecutarse en máquinas multi-core o con varios procesadores. Hasta hace pocos años una máquina con varios procesadores estaba prácticamente reservada a la supercomputación, pero hoy en día ya son comunes en cualquier PC de escritorio.
La programación concurrente y el paralelismo no son una materia trivial, la sincronización entre procesos no lo es y dividir el trabajo, asignarlo a varios procesadores, recoger los resultados y mezclarlos para reconstruir la solución final tampoco.
En el pasado, la paralelización requería manipulación de bajo nivel de los subprocesos y bloqueos. Hoy en día, los desarrolladores no tendrían que luchar contra esta complejidad, porque sería dar un paso atrás en productividad. Su tarea es seguir dedicando sus esfuerzos a lo que mejor saben hacer que es desarrollar aplicaciones de alto nivel que resuelven problemas de negocio sin controlar la ejecución concurrente de sus procesos.
Para ello, debemos acostumbrarnos a usar el término task en lugar del thread como seguramente la mayoría de nosotros veníamos haciendo y aprovechar las nuevas capacidades que ofrece las APIs de .NET Framework 4, así como también las del Visual Studio 2010.
.NET Framework 4, proporciona un nuevo runtime, nuevos tipos de biblioteca de clases y nuevas herramientas de diagnóstico para la programación paralela y concurrente. Es aquí donde hace su aparición Parallel Extensions, simplificando el desarrollo en paralelo, de modo de poder escribir código paralelo eficaz, específico y escalable de forma natural sin tener que trabajar directamente con subprocesos. Sin embargo, no todo código se presta para la paralelización por ejemplo, si un bucle realiza solo una cantidad reducida de trabajo en cada iteración o no se ejecuta para un gran número de iteraciones, la sobrecarga de la paralelización puede dar lugar a una ejecución más lenta del código. Además, al igual que cualquier código multiproceso, la paralelización hace que la ejecución del programa sea más compleja.
Parallel Extensions se compone de dos partes: Parallel Linq (PLINQ) y Task Parallel Library (TPL). También consta de un conjunto de estructuras de datos de coordinación (CDS) utilizada para sincronizar y coordinar la ejecución de tareas concurrentes. La siguiente ilustración proporciona información general de alto nivel de la arquitectura de programación paralela en .NET Framework 4.

A continuación explicaré cada uno de sus componentes. PLINQ implementa el conjunto completo de operadores de consulta estándar de LINQ e incluye otros adicionales para las operaciones paralelas. Combina la simplicidad y legibilidad de la sintaxis de LINQ con la eficacia de la programación paralela. En muchos escenarios, PLINQ puede aumentar significativamente la velocidad de las consultas LINQ to Objects utilizando todos los núcleos disponibles en el equipo host de una forma más eficaz. Para hacer esto divide el origen de datos en segmentos y, a continuación, ejecuta la consulta en cada segmento en subprocesos de trabajo independientes en paralelo en varios procesadores. La clase System.Linq.ParallelEnumerable expone casi toda la funcionalidad de PLINQ. Les mostraré un ejemplo en el que invoco el método de extensión ParallelEnumerableAsParallel() para indicarle que la query debe ejecutarse en forma paralela.
for (var i = 0; i < data.Length; i++)
{
data[i] = i;
}
data[1000] = -1;
data[14000] = -2;
data[15000] = -3;
data[676000] = -4;
data[8024540] = -5;
data[9908000] = -6;
var negativos = from valor in data.AsParallel()
where valor < 0
select valor;
Al ejecutarse la query en paralelo los resultados de cada subproceso de trabajo deben volver a combinarse en el subproceso principal por ejemplo para la inserción en una lista. El tipo de combinación que PLINQ realiza depende de los operadores que se encuentran en la consulta. Con el método WithParallelMergeOptions(), puede indicarse a PLINQ una sugerencia de que tipo de combinación se va a realizar.
La biblioteca TPL es un conjunto de API que tiene como propósito lograr que los desarrolladores aumenten la productividad simplificando el proceso de agregar paralelismo y simultaneidad a las aplicaciones, utilizando eficazmente todos los procesadores que están disponibles. Además, se encarga de la división del trabajo, la programación de los subprocesos y otros detalles de bajo nivel.
El concepto principal de Parallel Extensions es una tarea, que es una pequeña unidad de código, de forma independiente. Se encarga de dividir el trabajo y lanzar un número óptimo de threads basándose en el número de CPUs o cores que tenemos.
Yendo aun mas allá, sin llegar al nivel de granularidad de una task, podemos trabajar a más alto nivel aún con la clase estática Parallel y escribir código como el siguiente:
static void Main()
{
Parallel.For(from, to, i=> )
{
});
}
Mediante un ciclo como el anterior, dejamos que el runtime de Parallel Extensions se encargue de paralelizar el trabajo creando las tasks y encargándose de todo el proceso. A continuación les mostraré las mejoras de rendimiento al paralelizar un programa muy sencillo. El método SumaRaicesNesimaDeX devuelve la suma de las raíces n-énesima de todos los enteros entre 1 y un 10 millones donde n es una variable recibida como parámetro. La forma de resolución de la raíz contiene pasos de procesamiento adicionales que aumentan el tiempo de ejecución total de la consulta. Además, en el main utilizo la clase Stopwatch para determinar cuantos milisegundos tarda el programa en ejecutarse.
static void Main()
{
var tiempo = Stopwatch.StartNew();
for (var i = 2; i < 20; i++)
{
var resultado = SumaRaicesNesimaDeX(i);
Console.WriteLine("Raiz {0} = {1} ", i, resultado);
}
Console.WriteLine(tiempo.ElapsedMilliseconds);
Console.ReadLine();
}
public static double SumaRaicesNesimaDeX(int n)
{
double resultado = 0;
for (var x = 1; x < 10000000; x++)
{
//Raíz n-ésima de x
resultado += Math.Exp(Math.Log(x) / n);
}
return resultado;
}
El programa lo ejecute en AMD Phenom II X4 810 de 64 bits con 4 GB de memoria RAM y tardó aproximadamente 16 segundos.
Ahora si remplazo el ciclo:
for (var i = 2; i < 20; i++)
{
var resultado = SumaRaicesNesimaDeX(i);
Console.WriteLine("Raiz {0} = {1} ", i, resultado);
}
por el siguiente código añadiéndole paralelismo:
Parallel.For(2, 20, (i) =>
{
var resultado = SumaRaicesNesimaDeX(i);
Console.WriteLine("Raiz {0} = {1} ", i, resultado);
});
Mediante pequeñas modificaciones del código como son un índice de inicio y de fin y la llamada a través de un delegate, la ejecución del programa solo tarda aproximadamente 4 segundos y medio.
Visual Studio 2010 incluye nuevas herramientas de profiling y debugging para la ejecución concurrente. Un ejemplo de esto es el visualizador de concurrencia. Esta herramienta ayuda a analizar nuestras aplicaciones secuenciales para descubrir las oportunidades de paralelismo. El visualizador de concurrencia incluye visualización y herramientas de reporte. Hay tres puntos de vista principales: utilización de CPU, subprocesos y núcleos.
El eje X muestra el tiempo transcurrido desde el inicio de la traza hasta el final de la actividad de la aplicación. El eje Y muestra el número de núcleos de procesadores lógicos en el sistema. La zona verde representa el número medio de cores lógicos que se analiza en la aplicación en un momento dado en la ejecución del profiling. El resto de los núcleos por otros procesos que se ejecutan en el sistema (que se muestra en amarillo).
Si queremos paralelizar nuestra aplicación, debemos buscar áreas de ejecución que presentan largas regiones verdes a nivel de un solo núcleo en el eje Y o regiones donde no hay mucha utilización de la CPU, donde el verde no demuestra ni es considerablemente menos de 1 en promedio. Ambas circunstancias podrían indicar una oportunidad para la paralelización. En segundo lugar, si estamos tratando de afinar la aplicación paralela, esta vista le permite confirmar el grado de paralelismo que existe cuando la aplicación se ejecuta.
Este cambio de paradigma nos exige una nueva manera de pensar. Los threads son una abstracción demasiado débil. Debemos empezar a pensar en tareas no en threads y en relaciones entre esas tareas y su concurrencia en lugar de sincronización de threads. Seremos los desarrolladores quienes de un modo u otro tendremos que empezar a pensar en paralelización.