Twitter Facebook RSS Feed

jueves, 15 de octubre de 2009 a las 15:36hs por Gustavo Cantero (The Wolf)

Tal como fue expresado por Gordon Moore, cofundador de Intel, en 1965 en la conocida “Ley de Moore”, la cantidad de transistores que poseen las computadoras se duplicaría aproximadamente cada 18 meses (un año y medio), y esta tendencia continuaría durante las siguientes dos décadas. Unos diez años después corrigió su ley diciendo que la cantidad de transistores se duplicará cada 24 meses (2 años). Esta ley fue cumplida durante mucho tiempo (mucho más del que él pensaba al momento de formularla) generando una carrera en la generación de procesadores cada vez más potentes, hasta hace unos pocos años. Al llegar al límite (o muy cerca) del tamaño que se le puede dar a éstos transistores (por lo menos utilizando las técnicas actuales) se comenzaron a generar nuevos procesadores con varios núcleos, o sea, varios procesadores en una misma “pastilla”. Esto dio origen a los primeros procesadores Multi-Core, los cuales hoy en día son muy comunes en los equipos de escritorio y notebooks.
Hasta aquí todo parece ser bueno, pero para los desarrolladores no lo es tanto, ya que las aplicaciones que venimos desarrollando son, por lo general, creadas para ejecutarse en un único hilo (thread), y para poder utilizar el poder de estos nuevos procesadores hay que cambiar la forma de programar. Supongamos que tenemos una aplicación que calcula los números primos existentes entre el 1 y 1.000.000.000, seguramente esta tarea llevaría bastante tiempo, y aunque lo ejecutemos en una máquina con ocho núcleos (o, por ejemplo, cuatros procesadores con doble núcleo) veremos que sólo uno de los procesadores trabajará al 100%, mientras que el resto no realizará ninguna tarea. Para mejorar la performance de esta aplicación en esta máquina lo optimo sería distribuir la tarea en ocho threads para que cada uno se ejecute en un procesador distinto y reducir de esta manera el tiempo en conseguir el resultado.
Crear estos threads y distribuir la carga de trabajo hace que nuestro código sea más largo, difícil de leer, propenso a errores, y el crear ocho threads (como comentamos antes) no siempre es lo mejor, ya que esto depende de la carga de los procesadores y de la cantidad de éstos que dispongamos en cada máquina donde se vaya a ejecutar nuestra aplicación. Para facilitar estas tareas aparece la .NET Task Parallel Library, o como se la conoce, la TPL.

Task Parallel Library

Esta librería fue creada en un esfuerzo en conjunto del Microsoft® Research, el equipo del Parallel Computing Platform y el equipo del Microsoft® Common Language Runtime (CLR). La misma se encuentra incluida en la beta 1 del .NET Framework 4.0, pero puede utilizarse su versión CTP para .NET Framework 3.5 descargándola de la siguiente página: Microsoft Parallel Extensions to .NET Framework 3.5, June 2008 Community Technology Preview.
Cabe mencionar que en .NET Framework 4.0 hay muchas novedades sobre paralelismo, por ejemplo, está Parallel LINQ (también conocido como PLINQ), una librería para utilizar LINQ distribuyendo su carga en los procesadores disponibles, Parallel Pattern Library (o PPL) una librería de patrones utilizados en algoritmos concurrentes, y varias cosas más que exceden el alcance de este artículo.
TPL nos brinda distintas clases y métodos para distribuir y hacer un balance de carga de las tareas que debe realizar nuestra aplicación sobre los distintos procesadores que tengamos disponibles en la máquina en tiempo de ejecución. Por ejemplo, supongamos que tenemos una máquina con un procesador de doble núcleo y nuestra aplicación debe realizar tres tareas, el TPL ejecutará las dos primeras, una en cada núcleo, y al concluir cualquiera de éstas comenzará a ejecutar la tercera tarea en el núcleo disponible. Si esta misma aplicación la corremos en una máquina que posea más de dos núcleos ejecutará las tres tareas al mismo tiempo.
Hay que mencionar que la creación, administración y sincronización de estas tareas y threads por parte del TPL generan una carga extra de trabajo, pero ésta es muy pequeña y la ganancia al utilizar todos los procesadores de la máquina es enorme.
En .NET Framework 4.0 esta librería está incluida en el archivo mscorlib.dll, con lo cual podemos utilizarla sin necesidad de agregar librerías, pero si queremos usarla desde una aplicación desarrollada con .NET 3.5, además de bajar e instalar el CTP desde la dirección mostrada anteriormente, tenemos que referenciar el archivo System.Threading.dll desde nuestro proyecto.
La librería posee complejos algoritmos para la distribución dinámica de la carga entre los procesadores, pero esto sólo representa una posible ejecución en paralelo, ya que en una máquina con un único procesador las iteraciones se ejecutarán de manera secuencial en el mismo thread. Sin embargo hay que tener en cuenta que cuando utilizamos esta librería en una máquina multi-core es muy probable que se ejecuten varias acciones al mismo tiempo, por lo tanto es necesario tratar de evitar el uso compartido de variables entre éstas ya que podrían solaparse, pero para el caso en que esto sea necesario, más adelante mostraré como se puede hacer de manera segura.

Parallel.For

La clase Parallel, del namespace System.Threading, posee varios métodos estáticos para realizar operaciones paralelas de forma sencilla y sin mucho código agregado. Uno de esos métodos es el For, el cual crea un ciclo (al igual que su sentencia homónima) donde se van a ejecutar tantas iteraciones al mismo tiempo como procesadores libres disponga la aplicación. Este método posee tres parámetros: el número desde el cual se realizará el ciclo (inclusive), el número hasta el que se realizará (excluido) y un delegado:

Parallel.For(int desdeInclusive, int hastaExcluido, Action acción)

Action es un delegado con generic a ejecutar: aquí deberemos poner el delegado con el código a correr en cada iteración. Para entenderlo mejor vamos a hacer un ejemplo: supongamos que necesitamos obtener la cantidad de números primos que hay entre el número 0 y el 99.999, comúnmente haríamos algo como esto:

int cant1 = 0;
for (int valor = 0; valor < 100000; valor++)
{
    if (valor < 2)
        cant1++;
    else
    {
        bool divisible = false;
        for (int temp = 2; !divisible && temp < valor; temp++)
        {
            if (valor % temp == 0)
                divisible = true;
        }
        if (!divisible)
            cant1++;
    }
}

Este ejemplo es un algoritmo sencillo creado sólo para esta demostración y no es la manera más eficiente de verificar si un número es primo ya que se le podrían hacer mejoras, por ejemplo, verificando la divisibilidad sólo hasta el resultado de la raíz cuadrada del valor a verificar. Esto mismo sucede con los algoritmos que modificaremos para que utilicen paralelismo, los cuales aunque usen TPL trataremos de que queden lo más parecidos posibles al original para comparar sus tiempos.
Volviendo al ejemplo, si modificamos el ciclo para que utilice Parallel.For, como en el siguiente código, probablemente el resultado lo devolverá en mucho menos tiempo, dependiendo de los procesadores disponibles de nuestra máquina:

int cant2 = 0;
Parallel.For(0, 100000, (valor) =>
{
    if (valor < 2)
        cant2++;
    else
    {
        bool divisible = false;
        for (int temp = 2; !divisible && temp < valor; temp++)
        {
            if (valor % temp == 0)
                divisible = true;
        }
        if (!divisible)
            cant2++;
    }
});

En el ejemplo hay dos ciclos “for” que se podrían ejecutar con paralelismo, pero gracias al primero tenemos hasta 100.000 tareas potencialmente ejecutables en paralelo y, al menos hasta dentro de varios años, no existen PCs de escritorio con tantos procesadores, por lo tanto crear otro ciclo anidado con paralelismo sólo generaría un overhead innecesario. Otro punto que posee esta rutina es que los distintos threads van a acceder a la variable “cant2”, lo cual podría generar errores en la contabilidad de números primos encontrados, pero más adelante veremos cómo resolver este tema. Para los que no están familiarizados con las expresiones lambda, cabe mencionar que en C# el código:

(valor) => {...}

es equivalente a esto:

delegate(int valor) {...}

Al ejecutar ambos códigos, en mi computadora que posee un procesador de doble núcleo, el primero demoró 11,241 segundos en recorrer los 100.000 números y verificar cuales eran primos, mientras que el segundo (que utiliza paralelismo) demoró 5,883 segundos, casi la mitad de tiempo. Si vemos el gráfico de utilización de los procesadores creado por el administrador de tareas (lo configuré para que muestre la suma de los procesadores) veremos cuanto consumió del total disponible cada rutina:

Administrador de tareas y TPL

Parallel.ForEach

Hay veces donde no nos sirve recorrer una secuencia ordenada de valores como nos ofrece el ciclo Parallel.For, sino que necesitamos recorrer los valores de una colección o un vector y, por cada ítem, realizar una acción. Para estos casos TPL posee otro ciclo llamado Parallel.ForEach, el cual realiza la misma acción que el ciclo foreach clásico pero con paralelismo, tomando cada uno de los ítems de un objeto que herede de IEnumerable. Supongamos que en lugar de querer obtener la cantidad de números primos entre el 0 y el 99.999 necesitamos saber cuáles números de una lista son primos. La lista que vamos a utilizar para el ejemplo es la que se muestra a continuación, a la cual le agregué 84 números primos para hacer que la tarea sea más larga (ya que cuando un número es primo se verifica si es divisible por todos los números entre 2 y ese mismo número menos uno):

int[] numeros = new int[] {
    1998697, 1998701, 1998727, 1998739, 1998761, 1998793,
    1998817, 1998827, 1998839, 1998881, 1998917, 1998923,
    1998943, 1998947, 1998949, 1998961, 1998977, 1998991,
    1999007, 1999021, 1999033, 1999043, 1999061, 1999069,
    1999099, 1999103, 1999111, 1999121, 1999163, 1999177,
    1999187, 1999211, 1999219, 1999223, 1999243, 1999247,
    1999817, 1999819, 1999853, 1999859, 1999867, 1999871,
    1999889, 1999891, 1999957, 1999969, 1999979, 1999993,
    1999273, 1999297, 1999301, 1999303, 1999307, 1999331,
    1999339, 1999343, 1999363, 1999379, 1999423, 1999441,
    1999471, 1999499, 1999511, 1999513, 1999537, 1999549,
    1999559, 1999561, 1999567, 1999603, 1999607, 1999619,
    1999631, 1999633, 1999651, 1999661, 1999667, 1999681,
    1999691, 1999703, 1999721, 1999733, 1999771, 1999799,
    1000000, 34000000, 7654321, 9876543, 76544321, 2000000000,
    1745345434, 2143945834, 1232435643, 2123432424 };

Un ejemplo de cómo realizar la tarea de forma secuencial utilizando esta lista sería como se muestra en el siguiente código:

int cant1 = 0;
foreach (int valor in numeros)
{
    if (valor < 2)
        cant1++;
    else
    {
        bool divisible = false;
        for (int temp = 2; !divisible && temp < valor; temp++)
        {
            if (valor % temp == 0)
                divisible = true;
        }
        if (!divisible)
            cant1++;
    }
}

Pero si queremos utilizar paralelismo para acelerar la ejecución podríamos hacerlo de esta forma:

int cant2 = 0;
Parallel.ForEach(numeros, (valor) =>
{
    if (valor < 2)
        cant2++;
    else
    {
        bool divisible = false;
        for (int temp = 2; !divisible && temp < valor; temp++)
        {
            if (valor % temp == 0)
                divisible = true;
        }
        if (!divisible)
            cant2++;
    }
});

Este código en mi PC con sin y con paralelismo demoró 5,482 y 2,849 segundos en ejecutar respectivamente.
Cuando se utiliza la librería TPL hay que tener cuidado de no intentar reducir el tiempo de respuesta de nuestras aplicaciones ejecutando en paralelo rutinas que demoran muy poco, o de anidar rutinas que ya se ejecutan con paralelismo, ya que el overhead y la asignación de memoria de las variables de los delegados puede hacer que el tiempo empleado en ejecutarlas se incremente. Por ejemplo, si en el código anterior comentamos las primeras líneas del la asignación del vector dejando sólo las últimas 3 líneas, con un total de 16 números (de los cuales sólo 6 son primos) los tiempos cambian, demorando en mi máquina 0,395 segundos en ejecutarse secuencialmente y 0,507 segundos en ejecutarse con paralelismo. La decisión de que rutinas ejecutar en paralelo y cuáles no es una tarea que se aprende con el análisis, las pruebas y la experiencia.

Parallel.Invoke

Los ciclos anteriores son muy útiles para muchos casos, pero no sirven si necesitamos ejecutar distintas subrutinas en paralelo (en realidad se puede hacer con un switch o case dentro del ciclo, pero no es muy claro a la hora de leer o modificar el código). Para esto la clase Parallel provee otro método estático llamado Invoke, el cual toma como parámetro un vector de delegados (params en C# o ParamArray en Visual Basic), los cuales apuntan a las distintas subrutinas que son potencialmente ejecutables en paralelo.
Por ejemplo, supongamos que tenemos que correr tres tareas independientes que no necesitan ejecutarse secuencialmente, podríamos hacerlo de la siguiente manera:

Parallel.Invoke(Tarea1, Tarea2, Tarea3);

Los tres métodos de la clase Parallel poseen la misma forma de manejar los errores: si en alguna de las tareas se genera un error, se cancelarán todas las tareas pendientes y se lanzará la excepción AggregateException, con las excepciones generadas dentro de su propiedad InnerExceptions.

Tareas (Tasks)

Cuando utilizamos alguno de los métodos antes descriptos la ejecución de la aplicación queda detenida en esa línea hasta que se terminen de correr todas las tareas pendientes. Otra de las posibilidades que tenemos para ejecutar código en paralelo utilizando TPL es creando objetos Task, los cuales se pueden lanzar y seguir ejecutando el código de la aplicación.
La clase Task, del namespace System.Threading.Tasks, posee varios constructores, pero el más sencillo tiene como parámetro un delegado que debe apuntar a la tarea a ejecutar en paralelo. Luego de creada, cuando queremos que comience a correr, necesitamos ejecutar el método Start, el cual le indicará que, cuando disponga de un procesador libre, inicie la ejecución de la tarea.
Si usamos el CTP hay algunas diferencias en la creación y utilización de esta clase en comparación con la beta 1 de .NET 4.0, por ejemplo, para crearlo el lugar de utilizar el constructor necesitamos usar el método estático Create, el cual admite los mismos parámetros que mencioné antes. Una vez creado este objeto la tarea estará disponible para ejecutarse en paralelo apenas disponga de un procesador libre donde correr (que puede ser inmediatamente) sin necesidad de llamar al método Start (el cual aquí no existe).
Si necesitamos asegurarnos de que la tarea se haya ejecutado en algún punto de nuestro código simplemente debemos utilizar el método Wait, el cual esperará hasta que termine su ejecución. Si necesitamos esperar como máximo durante un tiempo determinado, este método tiene dos sobrecargas, las cuales poseen parámetros para suministrarle un TimeSpan o un entero para establecer el tiempo o la cantidad de milisegundos de espera máxima respectivamente. En caso de estar corriendo la aplicación en una máquina con un solo procesador, al momento de llamar al método Wait se comenzará a ejecutar la tarea, ya que la creación de un nuevo thread, en una máquina donde no hay más procesadores donde alojarlo, genera una disminución en la performance. Cualquier excepción generada en la rutina ejecutada por la tarea es relanzada al ejecutar este método.
También disponemos de los métodos estáticos WaitAll y WaitAny, los cuales poseen las mismas sobrecargas que Wait, pero en lugar de poder pasarle como parámetro un objeto Task se puede pasar un vector de estos objetos, permitiendo esperar hasta que se hayan ejecutado todas éstas tareas (WaitAll) o cualquier de ellas (WaitAny).
Por ejemplo, supongamos que queremos ejecutar tres tareas en paralelo y luego, una vez que hayan finalizado, avisarle al usuario que el proceso ha concluido. Para esto necesitamos crear tres objetos Task que “apunten” a las rutinas a ejecutar y esperar que las todas finalicen. Un ejemplo de eso es el siguiente código:

//Creo los objetos Task
Task[] tareas = new Task[] {
    new Task(Rutina1),
    new Task(Rutina2),
    new Task(Rutina3)};

//Inicio las tareas
tareas[0].Start();
tareas[1].Start();
tareas[2].Start();

//Espero que se ejecuten
Task.WaitAll(tareas);

MessageBox.Show("¡Listo!");

Como comenté antes, el constructor de esta clase posee varias sobrecargas, pero casi todas tienen un parámetro para asignar el valor que se le pasará a la rutina a invocar. En el CTP del TPL (no así en la beta 1 del .NET Framework 4.0) este parámetro no es opcional, por lo tanto las rutinas deben poseer, aunque no se use, un parámetro de entrada.
En caso de que queramos cancelar una tarea poseemos los métodos Cancel y CancelWait. Lo que ambos hacen es cambiar el valor de la propiedad booleana de sólo lectura IsCancellationRequested a “verdadero” (excepto en el CTP que usa la propiedad IsCanceled). Esto no significa que la invocación de éstos métodos detengan la ejecución de la tarea, sino que somos nosotros, dentro de la rutina a ejecutarse en el Task, los encargados de verificar periódicamente el valor de esta propiedad para que, en caso de modificarse, detener la ejecución. Para obtener el valor de esta propiedad desde la rutina podemos utilizar la propiedad estática Current de la clase Task, la cual nos devuelve, en caso de estar corriendo en el thread de una tarea, una referencia a la misma, desde la cual podemos obtener el valor de la propiedad en cuestión.
El método CancelWait se diferencia de Cancel en que éste espera hasta que la tarea haya sido cancelada y haya dejado de correr para continuar con el hilo de ejecución. Al igual que el método Wait, posee dos sobrecargas para establecer el tiempo máximo de espera.
Otra propiedad interesante de la clase Task de la cual podemos hacer uso es IsCompleted, cuyo valor es verdadero sólo en caso de que la tarea ya haya finalizado, o falso en caso contrario.
En la beta de .NET Framework 4.0 agregaron una nueva propiedad (no disponible en el CTP para .NET 3.5) llamada IsFaulted, la cual vale verdadero sólo si la tarea fue finalizada por una excepción no controlada.
Si necesitáramos anidar varias rutinas en una tarea, o sea, que una tarea se ejecute luego de finalizada otra, podemos utilizar el método ContinueWith, al cual el podemos establecer distintas rutinas a ejecutarse luego de finalizada la actual o sólo según su estado de finalización. Por ejemplo, si queremos que luego de ejecutarse una tarea determinada se ejecute la rutina Fin, sin importar su estado al finalizar, deberíamos ejecutar el siguiente código:

Tarea.ContinueWith(Fin);

Pero para hacerlo más interesante vamos a pensar en un ejemplo un poco más complejo: supongamos que necesitamos hacer que se ejecute la tarea, si ésta finalizó correctamente que ejecute el método Finalizar, pero si produjo una excepción que antes ejecute CorrecionError y luego el método Finalizar. El gráfico siguiente muestra lo que queremos hacer de forma visual:

Diagrama de ejemplo de Task.ContinueWith

Para hacer lo anteriormente expuesto necesitamos utilizar una sobrecarga del método ContinueWith, la cual necesita como segundo parámetro un valor del enumerador TaskContinuationOptions, el que nos permite establecer bajo que condición de finalización de la tarea queremos ejecutar la siguiente. Este enumerador posee varios posibles valores, por ejemplo, correr sólo cuando se haya cancelado la tarea anterior (OnlyOnCanceled), sólo cuando no haya lanzado excepciones (NotOnFaulted) o cuando si las haya lanzado (OnlyOnFaulted), cuando haya finalizado correctamente (OnlyOnRanToCompletion), y varias opciones más.
En la versión del CTP este enumerador no existe, en su lugar se utiliza el TaskContinuationKind, el cual dispone de algunas opciones como OnCompletedSuccessfully, que ejecutará la tarea sólo en caso de que la actual finalice satisfactoriamente, OnFailed la ejecutará solamente si lanzó una excepción, OnAborted para que se ejecute cuando se haya cancelado la tarea, u OnAny, donde se ejecutará al finalizar la tarea actual sin importar su estado o la causa por la cual terminó su ejecución.
Entonces, para pasar a código de .NET 4.0 lo que expuse en el gráfico, deberíamos escribir lo siguiente:

//Creo la tarea para que utilice el método "Rutina"
Task Tarea = new Task(Rutina);

//Especifico que rutina ejecutar en caso de generarse una excepción
Task TareaConError = Tarea.ContinueWith(CorreccionError, TaskContinuationOptions.OnlyOnFaulted);

//Especifico que rutina ejecutar al finalizar la tarea
Task TareaFinalizar = Tarea.ContinueWith(Finalizar, TaskContinuationOptions.OnlyOnRanToCompletion);
Task TareaFinalizar2 = TareaConError.ContinueWith(Finalizar);

//Inicio la tarea
Tarea.Start();

Pero si quisiéramos ejecutarlo con el CTP debería ser algo como esto:

//Creo la tarea para que utilice el método "Rutina"
Task Tarea = Task.Create(Rutina);

//Especifico que rutina ejecutar en caso de generarse una excepción
Task TareaConError = Tarea.ContinueWith(CorreccionError, TaskContinuationKind.OnFailed);

//Especifico que rutina ejecutar al finalizar la tarea
Task TareaFinalizar = Tarea.ContinueWith(Finalizar, TaskContinuationKind.OnCompletedSuccessfully);
Task TareaFinalizar2 = TareaConError.ContinueWith(Finalizar);

Hay que tener en cuenta que a las rutinas llamadas utilizando el método ContinueWith no se les puede pasar valores como parámetros, en cambio debe tener como parámetro un objeto del tipo Task, donde se pasará la instancia de la tarea previa. Por ejemplo, la rutina CorreccionError debería quedar parecida a esto:

private void CorreccionError(Task TareaPrevia)
{
    Exception ex = TareaAnterior.Exception;
    //Acá hago algo dependiendo de la excepción
}

Future o Task<T>

En la versión CTP existe una clase llamada Future<T>, la cual hereda de Task, e implementa un viejo concepto existente en multi-lisp: nos permite crear una tarea para obtener o calcular un valor, que ésta se ejecute en paralelo y, al momento de necesitarlo, obtener el resultado de la rutina llamada desde la tarea o, en caso de que aún no haya sido ejecutada, correrla en el thread actual y luego obtener su valor utilizando la propiedad Value. Un ejemplo sería algo como lo siguiente:

Future objValor = Future.Create(CalcularValor);
//...
//Hago otros cálculos
//...
int Resultado = objValor.Value;

Para lo cual el método CalcularValor debería ser parecido al siguiente:

private int CalcularValor()
{
    //Calculo el valor a devolver
    return resultado;
}

En .NET 4.0 la clase Future ya no existe, pero en cambio existe una sobrecarga de la clase Task: Task<T>, la cual nos permite realizar lo mismo que con Future pero solamente usando una sintaxis diferente:

Task objValor = new Task(CalcularValor);
//...
//Hago otros cálculos
//...
int Resultado = objValor.Result;

Nótese que aquí en lugar de usar la propiedad Value, al usar la versión con generic de la clase Task, disponemos de la propiedad Result.

Concurrencia y bloqueos

En el primer ejemplo que hicimos con paralelismo comenté que podría haber problemas en el uso de la variable cant2 porque era usada desde las distintos threads. Esta situación podría generar que el valor de la variable no fuera el correcto. Imaginemos que dos threads quieren incrementar el valor de esta variable al mismo tiempo, podría darse la siguiente situación:

Thread 1 Thread 2 Valor de cant2
leo el valor de cant2 (que es 0) 0
leo el valor de cant2 (que es 0) 0
cant2 = valor anterior (0) + 1 1
cant2 = valor anterior (0) + 1 1

Entonces, luego de intentar incrementar el valor de cant2 (que inicialmente era cero) desde ambos threads, el resultado es que la variable vale 1, cuando debería valer 2. Esta situación es conocida como “race condition”.
Para corregir esto podemos bloquear el uso de las variables con la sentencia «lock» de C# o «SyncLock» de Visual Basic. La utilización de esta sentencia previene el uso del objeto a bloquear por parte de otros threads, lo que en nuestro ejemplo generaría que el thread 2 esperaría hasta que el primero termine de leer y modificar la variable compartida antes de poder utilizarla. Esta sentencia toma como parámetro el objeto a bloquear, el cual libera al finalizar el bloque de código. En nuestro caso necesitamos otro objeto para utilizarlo como “marca” de bloqueo, ya que la variable es un entero y no puede utilizarse con el lock. El código de ejemplo de esto sería el siguiente:

int cant2 = 0;
object bloqueo = new object();
Parallel.For(0, 100000, (valor) =>
{
    if (valor < 2)
        lock (bloqueo)
        {
            cant2++;
        }
    else
    {
        bool divisible = false;
        for (int temp = 2; !divisible && temp < valor; temp++)
        {
            if (valor % temp == 0)
                divisible = true;
        }
        if (!divisible)
            lock (bloqueo)
            {
                cant2++;
            }
    }
});

Al ejecutar este código con bloqueo en mi PC y compararlo con el mismo código con paralelismo utilizado en el primer ejemplo, sucede algo curioso: sin bloqueo encontró 9591 números primos, mientras que con bloqueo encontró 9594. Eso quiere decir que se está dando la condición “race condition” en nuestro ejemplo.
Cabe mencionar que al utilizar la sentencia lock o SyncLock, cualquier thread que quiera utilizar o bloquear el objeto bloqueado quedará en espera hasta que se haya liberado.
Ahora bien, hay veces que necesitamos manejar distintas variables entre las distintas tareas, y bloquear todo al principio y liberarlo al final generaría que las tareas estén esperando la mayor parte de. Para reducir los tiempos de bloqueos y generar un código más legible (o sea, no tener locks por todas partes) el Framework de .NET dispone de la clase Interlocked, la cual posee varios métodos interesantes para la utilización de variables compartidas sin el problema de los bloqueos o la concurrencia de los threads, de los cuales mostraremos los más relevantes en la siguiente lista:

  • Add: permite sumar un valor a una variable
  • Decrement: permite restar un valor a una variable
  • Increment: incrementa el valor de una variable en uno
  • Exchange: devuelve el valor actual de una variable y le establece uno nuevo

Para nuestro ejemplo nos conviene utilizar el método Increment, con el cual nos quedaría lo siguiente:

int cant2 = 0;
Parallel.For(0, 100000, (valor) =>
{
    if (valor < 2)
        Interlocked.Increment(ref cant2);
    else
    {
        bool divisible = false;
        for (int temp = 2; !divisible && temp < valor; temp++)
        {
            if (valor % temp == 0)
                divisible = true;
        }
        if (!divisible)
            Interlocked.Increment(ref cant2);
    }
});

TaskScheduler (ex TaskManager)

Todas las tareas (objetos Task) son administradas por un objeto llamado TaskScheduler, el cual fue incluido en la beta 1 de .NET Framework 4.0, ya que en el CTP para .NET 3.5 el objeto encargado de realizar esta tarea era el TaskManager (que ya no existe).
TaskScheduler es una clase abstracta, con lo cual podemos heredarla y modificarla para crear nuestros propios “programadores de tareas”. Aunque la mayoría de las veces el TaskScheduler por defecto nos alcanzará para nuestros trabajos, puede ser que necesitemos crear uno que agregue algún tipo de prioridad a las tareas para ejecutarlas, o que queramos ejecutarlas usando LIFO o FIFO, o de alguna otra forma no convencional.
Siempre disponemos de un TaskScheduler (o TaskManager) por defecto, al que podemos acceder con la propiedad estática Default, por ejemplo: TaskScheduler.Default. Si creamos nuestra clase heredada de la abstracta y queremos utilizarla en una tarea, simplemente debemos especificarla en el constructor de la misma. Por ejemplo, supongamos que creamos una clase llamada MiTaskScheduler, la cual debe heredar de TaskScheduler:

public class MiTaskScheduler : TaskScheduler
{
    protected override IEnumerable GetScheduledTasks()
    {
        //Lógica para devolver la lista de tareas a ejecutar
    }

    protected override void QueueTask(Task task)
    {
        //Lógica para agregar una tarea la lista de tareas a ejecutar
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        //Lógica para establecer si la tarea pasada por parámetro
        //puede ejecutar de manera sincrónica, en cuyo caso, será ejecutada
    }
}

Y queremos correr la rutina “Rutina” utilizando esta clase, entonces deberíamos hacer lo siguiente para ejecutar el objeto Task:

MiTaskScheduler MiScheduler = new MiTaskScheduler();
Task Tarea = new Task(Rutina);
Tarea.Start(MiScheduler);

También se puede establecer el scheduler a usar en las tareas desde el método ContinueWith, lo que nos permite que una tarea use uno y al finalizar llame a otra tarea que utiliza un scheduler distinto.
El objeto TaskScheduler sabe qué cantidad de tareas se pueden ejecutar concurrentemente, valor que puede consultarse a través de la propiedad MaximumConcurrencyLevel. Este valor también podemos modificarlo en nuestras implementaciones de esta clase, con lo cual, nos permite hacer cosas como devolver siempre 1 en caso de estar en debug o dependiendo de un símbolo del compilador. Esto nos permite debugear de manera más clara, ya que todas las tareas se ejecutarán de forma secuencial, aunque obviamente no nos permite probar posibles problemas de concurrencia.
Como ejemplo podríamos establecer en el campo Conditional compilation symbols del proyecto (en la solapa Build) el valor “SINGLE_THREAD”, entonces podemos sobrescribir la propiedad MaximumConcurrencyLevel de la clase creada en el párrafo anterior:

public class MiTaskScheduler : TaskScheduler
{
    protected override IEnumerable GetScheduledTasks()
    {
        //Lógica para devolver la lista de tareas a ejecutar
    }

    protected override void QueueTask(Task task)
    {
        //Lógica para agregar una tarea la lista de tareas a ejecutar
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        //Lógica para establecer si la tarea pasada por parámetro
        //puede ejecutar de manera sincrónica, en cuyo caso, será ejecutada
    }

    public override int MaximumConcurrencyLevel
    {
        get
        {
#if SINGLE_THREAD
            return 1;
#else
            return base.MaximumConcurrencyLevel;
#endif
        }
    }
}

Ahora cada vez que queramos forzar a nuestro código a ejecutarse secuencialmente sólo debemos agregar el símbolo SINGLE_THREAD al compilador, o crear una nueva configuración desde el “Configuration manager” para ejecutarlo con o sin este símbolo de manera más fácil.
Si en algún momento dentro de nuestras tareas necesitamos obtener una referencia al scheduler que se está utilizando como contexto de la misma, podemos obtenerlo usando la propiedad estática Current de la clase TaskScheduler.
Por último voy a comentar una novedad que trae esta clase y que no está disponible en el CTP: aquellos que ya hayan programado con threads con Windows Forms o WPF habrán sufrido la necesidad de sincronizar los threads que están corriendo algún proceso en paralelo con el thread principal, que es el único que puede acceder a la interfaz gráfica. Esto ahora se puede hacer de una manera mucho más sencilla, ya que el TaskScheduler posee un método estático llamado FromCurrentSynchronizationContext que devuelve un objeto del mismo tipo, con el cual podemos ejecutar nuestra tarea en el thread que puede acceder a los controles gráficos. Por ejemplo, si necesitamos realizar una tarea en background y que cuando termine actualice la pantalla, podemos hacer algo así:

//Creo la tarea a correr de fondo
Task Tarea = new Task(RutinaBackground);

//Creo la tarea que actualizará la pantalla
Task TareaResultado = Tarea.ContinueWith(
    MostrarResultado,
    TaskContinuationOptions.OnlyOnRanToCompletion,
    TaskScheduler.FromCurrentSynchronizationContext());

//Inicio la tarea
Tarea.Start();

TaskFactory

Esta clase nos permite crear, ejecutar, asignar un TaskScheduler, establecer una cadena de Task y realizar otras tareas de forma más sencilla. Por ejemplo, como comenté antes, en la beta 1 de .NET 4.0 luego de crearse las tareas debe invocarse el método Start para que se agreguen a la lista de rutinas a ejecutar, pero si queremos hacer que apenas se cree ya esté lista para correr (como sucede con el CTP) podemos usar el método StartNew de este objeto, como se muestra a continuación:

Task Tarea = Task.Factory.StartNew(Rutina);

Otro ejemplo podría ser que al finalizar varias tareas se actualice en la pantalla el resultado (algo parecido a lo mostrado anteriormente), entonces podríamos hacer algo así:

//Creo las tareas a correr de fondo
Task[] tareas = new Task[] {
    new Task(Rutina1),
    new Task(Rutina2),
    new Task(Rutina3)};

//Creo la tarea que actualizará la pantalla
Task.Factory.ContinueWhenAll(
    tareas,
    MostrarResultado,
    TaskContinuationOptions.OnlyOnRanToCompletion,
    TaskScheduler.FromCurrentSynchronizationContext());

//Inicio las tareas
tareas[0].Start();
tareas[1].Start();
tareas[2].Start();

Conclusión

Esta librería posee varias otras novedades y los métodos aquí nombrados tienen varias otras sobrecargas, pero espero que esta introducción a la utilización de la Task Parallel Library les haya sido de utilidad y los haya incentivado a utilizarla.
Sólo cabe mencionar que les dejo el código de los ejemplos realizados con Visual Studio® 2008 y con la beta 1 de Visual Studio® 2010. El primero de éstos tiene como requerimiento que se tenga instalada la CTP de la librería.

Proyecto de ejemplo en Visual Studio 2010 beta 1
Proyecto de ejemplo en Visual Studio 2010 beta 1
Proyecto de ejemplo en Visual Studio 2008
Proyecto de ejemplo en Visual Studio 2008

 

0 comentarios »

Deja un comentario

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.