Multitarea I, clases básicas
Con este artículo comienza una serie en la que voy a revisar los mecanismos básicos que proporciona .NET Framework para la implementación de aplicaciones multitarea. En primer lugar voy a mostrar las clases básicas que permiten lanzar múltiples procesos y hacer una comparativa de rendimiento entre ellas.
La multitarea consiste en la ejecución simultánea de varios procesos, que también reciben el nombre de tareas o hilos. En los sistemas más antiguos, con un solo procesador de un único núcleo, resulta imposible ejecutar dos fragmentos de código al mismo tiempo, por lo que el sistema asigna un tiempo a cada tarea y va pasando de una a otra dando la sensación de que se están ejecutando simultáneamente. Este cambio de una tarea a otra implica almacenar el estado (registros, punto de ejecución, etc.) de la tarea en curso y recuperar el estado de la tarea a la que se pasa a continuación. Este cambio de contexto tiene un coste en tiempo de ejecución, por lo que, en sistemas monoprocesador, el proceso paralelo resulta más lento que la ejecución en serie del mismo código cuando se trata de realizar operaciones que solo implican tiempo de procesador, como por ejemplo cálculos matemáticos intensivos.
Sin embargo, las operaciones que pueden implicar tiempos de espera más o menos largos, como la lectura de ficheros en disco, consultas de base de datos o descarga de ficheros desde una red, son mucho más apropiadas para el uso de las operaciones de forma asíncrona, ya que, mientras estamos descargando un fichero, podemos emplear el procesador para realizar otras operaciones, en lugar de esperar a que termine la descarga para realizarlas.
Existen equipos con varios procesadores físicos separados que permiten la ejecución simultánea real de varios procesos, pero esto, además de ocupar más espacio físico en la placa base, complica mucho la circuitería, ya que hay que duplicarla para cada procesador, por lo que actualmente la tendencia es a encapsular varios procesadores, denominados núcleos, dentro de un mismo microchip. Esto tiene la ventaja de no necesitar circuitería adicional y permite una comunicación mucho más rápida entre los diferentes núcleos, por encontrarse a una distancia muy reducida unos de otros.
Para mostrar el uso de los diferentes mecanismos que permiten implementar la multitarea, he preparado una aplicación que contendrá los ejemplos de código utilizados en la serie. En este enlace puedes descargar el código fuente de la aplicación MultithreadingDemo, escrita en csharp con Visual Studio 2015. La aplicación utiliza la versión 4.5 de .NET Framework.
De momento, solo existe una opción, en el menú Demo, que permite comparar el rendimiento de las diferentes clases proporcionadas por la plataforma. Se trata de la opción Tasks, que abre una ventana MDI con el siguiente aspecto:
A la derecha se pueden ver los cuatro botones que permiten ejecutar el mismo proceso utilizando diferentes mecanismos y, debajo de ellos, podemos seleccionar el número de hilos o procesos a utilizar en algunas de las opciones.
El proceso que va a realizarse como ejemplo es el dibujo de una parte de un conjunto fractal, que requiere de bastantes cálculos utilizando variables de tipo double. El conjunto se dibuja en una región de 640 x 480 pixels, que se almacena en la variable global _bitmap, un array de enteros.
Con el botón Single se lanza el proceso de manera secuencial, sin utilizar para nada la multitarea. El cálculo se realiza 36 veces para realizar un promedio de tiempo de ejecución, descartando las 6 primeras ejecuciones, que suelen ser más lentas que las posteriores. Este es el bucle en el que se realizan los cálculos:
for (int i = 0; i < 36; i++)
{
double pi, qi;
double x, y, xx, yy, px, py;
pi = qi = _cCte;
px = _cX;
py = _cY;
DateTime dt = DateTime.Now;
for (int p = 0; p < 640; p++)
{
for (int q = 0; q < 480; q++)
{
xx = -0.01 + p * pi;
yy = 0.02 + q * qi;
for (int k = 1; k <= 1000; k++)
{
x = xx * xx - yy * yy + px;
y = 2 * xx * yy + py;
xx = x;
yy = y;
if (x * x + y * y > _cR)
{
_bitmap[p + 640 * q] = _colors[k % 10];
break;
}
}
}
}
if (i > 5)
{
sum += DateTime.Now.Subtract(dt).TotalMilliseconds;
}
}
Al finalizar, el tiempo de proceso en milisegundos aparece a la derecha del botón, y se crea un Bitmap en el que se copian los resultados del cálculo, que se muestra en pantalla en un control PictureBox:
bmp = new Bitmap(640, 480);
bf = bmp.LockBits(new Rectangle(0, 0, 640, 480),
ImageLockMode.ReadWrite, PixelFormat.Format32bppRgb);
Marshal.Copy(_bitmap, 0, bf.Scan0, 640 * 480);
bmp.UnlockBits(bf);
bf = null;
pbDrawing.Image = bmp;
Es preferible ejecutar la aplicación desde fuera del IDE de Visual Studio y compilada con la configuración Release para obtener los tiempos más realistas.
La primera opción, y la más antigua, para ejecutar este mismo proceso utilizando varios hilos es utilizar la clase Thread, en el espacio de nombres System.Threading. Al crear un objeto de este tipo, le asignamos un método que contendrá el código que debe utilizar la tarea. En este caso, se trata del método SectorThread, que dibuja solo un sector del conjunto final. El parámetro sector es un entero que indica que parte del conjunto debe dibujar la tarea, algo que depende del número de procesos seleccionados. Por ejemplo, con dos procesos se lanzarán dos tareas, cada una de las cuales dibujará la mitad del conjunto. Este es el código que se va a ejecutar:
private void SectorThread(object sector)
{
double pi, qi;
double x, y, xx, yy, px, py;
pi = qi = _cCte;
px = _cX;
py = _cY;
for (int p = _xIndex((int)sector, true);
p < _xIndex((int)sector, false); p++)
{
for (int q = _yIndex((int)sector, true);
q < _yIndex((int)sector, false); q++)
{
xx = -0.01 + p * pi;
yy = 0.02 + q * qi;
for (int k = 1; k <= 1000; k++)
{
x = xx * xx - yy * yy + px;
y = 2 * xx * yy + py;
xx = x;
yy = y;
if (x * x + y * y > _cR)
{
_bitmap[p + 640 * q] = _colors[k % 10];
break;
}
}
}
}
}
_xIndex e _yIndex son dos objetos de tipo IndexDelegate que proporcionan los límites inicial y final del sector correspondiente a la tarea en curso, algo que depende del número de procesos implicados.
private delegate int IndexDelegate(int sector, bool initial);
private IndexDelegate _xIndex = null;
private IndexDelegate _yIndex = null;
private int _processes = 4;
En general, los objetos Thread se crean con un constructor que recibe como parámetro un objeto ThreadStart que indica el método, sin parámetros, que debe ejecutar la tarea:
Thread t = new Thread(new ThreadStart(método));
Como en este caso el método necesita un parámetro que indica el sector a dibujar, utilizamos otra versión del constructor que permite indicar un método con un solo parámetro de tipo object, usando en el constructor un parámetro de tipo ParametrizedThreadStart:
Thread t = new Thread(new ParameterizedThreadStart(SectorThread));
Al pulsar el botón Thread se ejecuta el código que crea tantos objetos Thread como se haya indicado y los almacena en una lista. A continuación, se inicia cada tarea con el método Start, al que se le pasa como parámetro el índice del sector a dibujar. El uso de la variable local sc es necesario debido a que, si usamos directamente el índice del bucle, la tarea se puede iniciar cuando el valor de esta variable ya haya cambiado al siguiente valor, utilizando un índice incorrecto.
for (int i = 0; i < 36; i++)
{
threads.Clear();
for (int it = 0; it < _processes; it++)
{
threads.Add(new Thread(new ParameterizedThreadStart(SectorThread)));
}
DateTime dt = DateTime.Now;
for (int it = 0; it < threads.Count; it++)
{
int sc = it;
threads[it].Start(sc);
}
bool alive = true;
while (alive)
{
alive = false;
foreach (Thread t in threads)
{
alive = alive || t.IsAlive;
if (alive)
{
break;
}
}
}
if (i > 5)
{
sum += DateTime.Now.Subtract(dt).TotalMilliseconds;
}
}
Una vez lanzadas todas las tareas, esperamos en un bucle while a que terminen todas inspeccionando la propiedad IsAlive de cada una de ellas. Como en el caso anterior, realizamos un promedio de 30 ejecuciones, descartando las 6 primeras. En un sistema con varios procesadores o núcleos podremos observar que la ejecución lleva bastante menos tiempo que en el caso de usar un solo proceso.
En la versión 4 de .NET Framework se añadió una versión mejorada de la clase Thread, la clase Task del espacio de nombres System.Threading.Tasks. Esta clase hace un uso óptimo de los diferentes núcleos del procesador, además de proporcionar un interfaz mucho más rico para manejar las tareas. En la versión 4 de .NET Framework las tareas se crean y se lanzan a la vez llamando al método StartNew de la propiedad estática Factory de la clase Task, de la siguiente manera:
Task t = Task.Factory.StartNew(() => { SectorThread(s); });
Como se puede ver, se proporciona directamente el código a ejecutar por la tarea mediante una expresión. En la versión 4.5 de .NET Framework se puede hacer lo mismo utilizando el método estático Run de la clase Task:
Task t = Task.Run(() => { SectorThread(s); });
Usando el botón Task de la aplicación podemos comprobar el rendimiento de esta opción. Dependiendo del sistema operativo y el número de procesadores este valor puede ser superior, inferior o prácticamente igual al de la clase Thread, pero en general es preferible usar la clase Task debido a que proporciona unos mecanismos de control mucho más ricos que la clase Thread. Este es el código que lanza las diferentes tareas usando la clase Task, el procedimiento es el mismo que en el caso anterior, almacenando las tareas en una lista:
for (int i = 0; i < 36; i++)
{
foreach (Task t in tasks)
{
t.Dispose();
}
tasks.Clear();
DateTime dt = DateTime.Now;
for (int ix = 0; ix < _processes; ix++)
{
int s = ix;
Task t = Task.Run(() => { SectorThread(s); });
tasks.Add(t);
}
Task.WaitAll(tasks.ToArray());
if (i > 5)
{
sum += DateTime.Now.Subtract(dt).TotalMilliseconds;
}
}
Aquí podemos esperar a que todas las tareas hayan terminado utilizando el método estático WaitAll de la clase Task.
La última clase que vamos a ver es Parallel, en el espacio de nombres System.Threading.Tasks. Esta clase permite utilizar el código dentro de un bucle for o foreach utilizando para cada iteración una tarea diferente. Para ello posee los métodos estáticos For y ForEach. Esta es la clase que realiza el uso más óptimo de los diferentes núcleos del procesador, aunque no nos garantiza que los diferentes índices del bucle se vayan a ejecutar en orden, por lo que no resulta válida en casos en los que el orden sea importante.
Usando el botón Parallel lanzaremos el siguiente código:
for (int i = 0; i < 36; i++)
{
DateTime dt = DateTime.Now;
Parallel.For(0, 640, p =>
{
for (int q = 0; q < 480; q++)
{
double x, y, xx, yy;
xx = -0.01 + p * _cCte;
yy = 0.02 + q * _cCte;
for (int k = 1; k <= 1000; k++)
{
x = xx * xx - yy * yy + _cX;
y = 2 * xx * yy + _cY;
xx = x;
yy = y;
if (x * x + y * y > _cR)
{
_bitmap[p + 640 * q] = _colors[k % 10];
break;
}
}
}
});
if (i > 5)
{
sum += DateTime.Now.Subtract(dt).TotalMilliseconds;
}
}
Como se puede ver, usamos el bucle Parallel.For para cada una de las columnas verticales del cálculo del conjunto. Los dos primeros parámetros son los índices inicial y final del bucle, y el tercero es una expresión con la variable indexada y el fragmento de código que se va a ejecutar. Es importante definir las variables locales dentro del bucle for, ya que cada iteración se va a ejecutar en un proceso diferente. Este es el resultado de ejecutar las cuatro opciones:
Por supuesto, estos tiempos variarán cada vez que ejecutéis cada una de las opciones, y no siempre los procedimientos más óptimos se ejecutarán más rápido que el resto, pero en general es preferible usar Parrallel, si es posible, a usar Task, y usar Task en lugar de Thread. Por supuesto, el multiproceso, si existen varios procesadores, siempre será preferible a usar un solo proceso.
Eso es todo por ahora, en el próximo artículo mostraré cómo sincronizar varias tareas entre sí.