Você está na página 1de 18

Introducción a Task Parallel Library (TPL)

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<int> 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:

1 int cant1 = 0;
for (int valor = 0; valor <
2
100000; valor++)
3{
if (valor
4
< 2)
cant
5
1++;
6 else
7 {
bool divisible
8
= false;
for (int temp = 2; !divisible && temp
9
< valor; temp++)
10 {
1 if (valor %
1 temp == 0)
1 divisible =
2 true;
1
}
3
1 if (!
4 divisible)
1 cant
5 1++;
1
}
6
1
}
7

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:

1 int cant2 = 0;
Parallel.For(0, 100000,
2
(valor) =>
3{
if (valor
4
< 2)
cant
5
2++;
6 else
7 {
bool divisible
8
= false;
for (int temp = 2; !divisible && temp
9
< valor; temp++)
10 {
1 if (valor %
1 temp == 0)
1 divisible =
2 true;
1
}
3
1 if (!
4 divisible)
1 cant
5 2++;
1
}
6
1 }
7 );

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:

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:

1 int cant1 = 0;
foreach (int valor in
2
numeros)
3{
if (valor
4
< 2)
cant
5
1++;
6 else
7 {
bool divisible
8
= false;
for (int temp = 2; !divisible && temp
9
< valor; temp++)
10 {
1 if (valor %
1 temp == 0)
1 divisible =
2 true;
1
}
3
1 if (!
4 divisible)
1 cant
5 1++;
1
}
6
1
}
7

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


esta forma:

1 int cant2 = 0;
Parallel.ForEach(numeros,
2
(valor) =>
3{
if (valor
4
< 2)
cant
5
2++;
6 else
7 {
bool divisible
8
= false;
for (int temp = 2; !divisible && temp
9
< valor; temp++)
10 {
1 if (valor %
1 temp == 0)
1 divisible =
2 true;
1
}
3
1 if (!
4 divisible)
1 cant
5 2++;
1
}
6
1 }
7 );

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


1
Task
Task[] tareas = new
2
Task[] {
new
3
Task(Rutina1),
new
4
Task(Rutina2),
new
5
Task(Rutina3)};
6
//Inicio las
7
tareas
tareas[0].St
8
art();
9 tareas[1].Start();
tareas[2].St
10
art();
1
1
1 //Espero que se
2 ejecuten
1 Task.WaitAll(ta
3 reas);
1
4
1 MessageBox.Show("¡
5 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.ContinueWit
h(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:

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
1
método "Rutina"
2 Task Tarea = new Task(Rutina);
3
//Especifico que rutina ejecutar en caso de
4
generarse una excepción
Task TareaConError = Tarea.ContinueWith(CorreccionError,
5
TaskContinuationOptions.OnlyOnFaulted);
6
7 //Especifico que rutina ejecutar al finalizar la tarea
Task TareaFinalizar = Tarea.ContinueWith(Finalizar,
8
TaskContinuationOptions.OnlyOnRanToCompletion);
Task TareaFinalizar2 =
9
TareaConError.ContinueWith(Finalizar);
10
1 //Inicio la
1 tarea
1 Tarea.Sta
2 rt();

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

//Creo la tarea para que utilice el


1
método "Rutina"
2 Task Tarea = Task.Create(Rutina);
3
//Especifico que rutina ejecutar en caso de
4
generarse una excepción
Task TareaConError = Tarea.ContinueWith(CorreccionError,
5
TaskContinuationKind.OnFailed);
6
7 //Especifico que rutina ejecutar al finalizar la tarea
Task TareaFinalizar = Tarea.ContinueWith(Finalizar,
8
TaskContinuationKind.OnCompletedSuccessfully);
Task TareaFinalizar2 =
9
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:

1 private void CorreccionError(Task


TareaPrevia)
2{
Exception ex =
3
TareaAnterior.Exception;
//Acá hago algo dependiendo de
4
la excepción
5}

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<int> objValor =
1
Future<int>.Create(CalcularValor);
2 //...
//Hago otros
3
cálculos
4 //...
int Resultado =
5
objValor.Value;

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

private int
1
CalcularValor()
2{
//Calculo el valor a
3
devolver
4 return resultado;
5}

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<int> objValor = new


1
Task<int>(CalcularValor);
2 //...
//Hago otros
3
cálculos
4 //...
int Resultado =
5
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:

Valor de
Thread 1 Thread 2
cant2
leo el valor de cant2 (que
0
es 0)
leo el valor de cant2 (que
0
es 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:

1 int cant2 = 0;
object bloqueo = new
2
object();
Parallel.For(0, 100000,
3
(valor) =>
4{
if (valor <
5
2)
lock
6
(bloqueo)
7 {
cant
8
2++;
9
}
1 el
0 se
1
{
1
1 bool divisible
2 = false;
1 for (int temp = 2; !divisible && temp
3 < valor; temp++)
1
{
4
1 if (valor %
5 temp == 0)
1 divisible =
6 true;
1
}
7
1 if (!
8 divisible)
1 lock
9 (bloqueo)
2
{
0
2 can
1 t2++;
2
}
2
2
3 }
2 }
4 );

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:

1 int cant2 = 0;
Parallel.For(0, 100000,
2
(valor) =>
3{
if (valor
4
< 2)
Interlocked.Increment
5
(ref cant2);
6 else
7 {
8 bool divisible
= false;
for (int temp = 2; !divisible && temp
9
< valor; temp++)
10 {
1 if (valor %
1 temp == 0)
1 divisible =
2 true;
1
}
3
1 if (!
4 divisible)
1 Interlocked.Incremen
5 t(ref cant2);
1
}
6
1 }
7 );

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 :


1
TaskScheduler
2{
protected override IEnumerable<Task>
3
GetScheduledTasks()
4 {
//Lógica para devolver la lista de
5
tareas a ejecutar
6 }
7
protected override void
8
QueueTask(Task task)
9 {
//Lógica para agregar una tarea la lista
10
de tareas a ejecutar
1
1 }
1
2
1 protected override bool TryExecuteTaskInline(Task task, bool
3 taskWasPreviouslyQueued)
1
{
4
1 //Lógica para establecer si la tarea pasada por
5 parámetro
1 //puede ejecutar de manera sincrónica, en cuyo
6 caso, será ejecutada
1
7 }
1
}
8

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

MiTaskScheduler MiScheduler = new


1
MiTaskScheduler();
2 Task Tarea = new Task(Rutina);
Tarea.Start(MiSche
3
duler);

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 :


1
TaskScheduler
2{
protected override IEnumerable<Task>
3
GetScheduledTasks()
4 {
//Lógica para devolver la lista de
5
tareas a ejecutar
6 }
7
protected override void
8
QueueTask(Task task)
9 {
//Lógica para agregar una tarea la lista
10
de tareas a ejecutar
1
1 }
1
2
1 protected override bool TryExecuteTaskInline(Task task, bool
3 taskWasPreviouslyQueued)
1
{
4
1 //Lógica para establecer si la tarea pasada por
5 parámetro
1 //puede ejecutar de manera sincrónica, en cuyo
6 caso, será ejecutada
1
7 }
1
8
1 public override int
9 MaximumConcurrencyLevel
2
{
0
2
1 get
2
2 {
#if
2
SINGLE_THREA
3
D
2
return 1;
4
2
#else
5
2 return
6 base.MaximumConcurrencyLevel;
2 #en
7 dif
2
8 }
2
9 }
3
}
0

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


1
fondo
Task Tarea = new
2
Task(RutinaBackground);
3
//Creo la tarea que
4
actualizará la pantalla
Task TareaResultado =
5
Tarea.ContinueWith(
6 MostrarResultado,
TaskContinuationOptions.OnlyOnRanT
7
oCompletion,
TaskScheduler.FromCurrentSynchroniz
8
ationContext());
9
1 //Inicio la
0 tarea
1 Tarea.Sta
1 rt();

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


1
de fondo
Task[] tareas = new
2
Task[] {
new
3
Task(Rutina1),
new
4
Task(Rutina2),
new
5
Task(Rutina3)};
6
//Creo la tarea que
7
actualizará la pantalla
Task.Factory.ContinueWhenA
8
ll(
9 tareas,
MostrarResu
10
ltado,
1 TaskContinuationOptions.OnlyOnRanT
1 oCompletion,
1 TaskScheduler.FromCurrentSynchroniz
2 ationContext());
1
3
1 //Inicio las
4 tareas
1 tareas[0].St
5 art();
1 tareas[1].St
6 art();
1 tareas[2].St
7 art();

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.

Você também pode gostar