viernes, 19 de diciembre de 2008

Esperas paralelas

La programación concurrente suele tener sus particularidades que hace que mucha gente la evite siempre que puede lo que es una lástima porque muchas cosas se pueden hacer mejor cuando se aprovecha la posibilidad de que un proceso ejecute paralelo al actual. Es también una lástima porque luego aparecen muchas apliaciones que a pesar de ser muy útiles hay momentos en que quedan paradas si hacer nada en lo que se ejecuta algún proceso que demora mucho y que tampoco podemos interrumpir.

Uno de los errores de implementación que mas me encuentro cuando alguien nuevo en la materia intenta programar algo en paralelo son las esperas. Necesitamos esperar generalmente porque estamos a espera de algo que no ha sucedido, como que esté disponible algún recurso o que algún proceso termine. Además del hecho mismo de esperar, el programador consciente se protege de la eventualidad de que aquello por lo que estamos esperando nunca suceda.

Aunque en .NET hay varias formas de implementar las esperas, aparentemente lo primero que se le ocurre a mucha gente es esperar durante un tiempo, probablemente porque es lo mas fácil y porque existen equivalentes cuando no se programa en paralelo. Generalmente esta espera viene en dos sabores:

El del programador inconsciente:

using System;

namespace paralelos
{
class Program
{
static volatile bool EsperaPorMi = false;

static void Main(string[] args)
{
// Mandamos a ejecutar un proceso paralelo
System.Threading.ThreadPool.QueueUserWorkItem(Paralelo);

// Supuestamente aqui hacemos otras cosas antes de decidir esperar
// ...

System.Threading.Thread.Sleep(6000); // Esta es la espera

// La finalización del proceo paralelo será marcada por el valor de la
// variable EsperaPorMi
if (EsperaPorMi)
Console.WriteLine("El proceso paralelo ha terminado");
else
Console.WriteLine("El proceso paralelo no ha terminado");
Console.WriteLine("\nPresione cualquier tecla para salir");
Console.ReadKey();
}

// Este es el proceso paralelo
static void Paralelo(object estado)
{
// Esto es para simular una operación que demora cinco segundos
System.Threading.Thread.Sleep(5000);
EsperaPorMi = true;
}
}
}


Aqui el programador inconsciente supone que sabe cuanto debe demorar la operación paralela y por lo tanto lo único que tiene que hacer es esperar un poco mas y asi garantiza que cuando deja de esperar la operación ya terminó.

Desafortunadamente esta solución tiene varios fallos obvios.
Para empezar, determinar con exactitud el tiempo que demora una sección de código de un programa es poco menos que imposible en el 95% de los casos en un sistema convencional. Estos tiempos dependen mucho de la configuración sobre la que ejecuta el programa ( procesador y sus nucleos, otros programas instalados y corriendo, procesos del sistema operativo, eventos que ocurran como la abertura de una torre de cd o un acceso remoto al pc, y un largo etcétera ). Si además la operación por la que estamos esperando tiene que acceder a recursos fuera de la gestión pura del programa ( archivos, bases de datos, etc. ) mas impredecible se torna. Finalmente y en caso de que la operación sea muy demorada ( y por tanto también la espera ) mas nada es hecho durante ese tiempo y por tanto se pierde en parte el beneficio de la ejecución paralela.

El programador consciente toma en cuenta los problemas anteriores y hace esto:

using System;

namespace paralelos
{
class Program
{
static volatile bool EsperaPorMi = false;

static void Main(string[] args)
{
// Mandamos a ejecutar un proceso paralelo
System.Threading.ThreadPool.QueueUserWorkItem(Paralelo);

// Supuestamente aqui hacemos otras cosas antes de decidir esperar
// ...

Console.WriteLine("Presione cualquier tecla para interrumpir");
// Ahora viene la espera
while (!EsperaPorMi)
{
System.Threading.Thread.Sleep(500); // Dormimos poco

if (Console.KeyAvailable) // El usuario se cansó de esperar ?
{
Console.Read();
break;
}
}

// La finalización del proceo paralelo será marcada por el valor de la
// variable EsperaPorMi
if (EsperaPorMi)
Console.WriteLine("El proceso paralelo ha terminado");
else
Console.WriteLine("El proceso paralelo no ha terminado");
Console.WriteLine("\nPresione cualquier tecla para salir");
Console.ReadKey();
}

// Este es el proceso paralelo
static void Paralelo(object estado)
{
// Esto es para simular una operación que demora cinco segundos
System.Threading.Thread.Sleep(5000);
EsperaPorMi = true;
}
}
}

En este caso el programador no pretende saber cuanto demora el proceso paralelo asi que en vez de esperar dormido durante tiempo suficiente lo que hace es verificar continuamente el estado de la variable de terminación y además verifica si el usuario pulsó una tecla indicando que ya no quiere esperar mas. Sin embargo, consciente como es, entre consulta y consulta duerme un poco, por que ?

El encargado de ejecutar las instrucciones de nuestro programa ( o mas estrictamente hablando , en lo que ellas se convierten ) es el procesador de la computadora. En un pc suelen haber en la mayoría de los casos de 1 a 4 procesadores o nucleos de procesadores con lo cual en cada momento solo pueden estarse ejecutando tantos procesos como procesadores x nucleos tengamos. Si nos damos un paseo por el task manager ( Ctrl+Shift+Esc ) y vamos a la pestaña de procesos vamos a ver que potencialmente hay muchos procesos que pueden necesitar de ejecutar y cada uno de ellos puede estar formado por muchos hilos cada uno de los cuales también necesita correr. El sistema operativo "planifica" al procesador para que le dedique un intervalo de tiempo a cada proceso/hilo y pasado ese tiempo pase a otro; eso ocurre muchas veces en cada milisegundo con lo cual nos da la idea de que se pueden ejecutar en paralelo mas procesos/hilos de los que serían posibles a juzgar por la cantidad de procesadores y nucleos que tengamos. Por lo tanto podemos concluir que el tiempo de procesador es un recurso bastante escaso, por eso el sistema no planifica al procesador ciegamente y una de las optimizaciones básicas que hace es no darle tiempo de procesador a quien no lo necesita, por lo tanto si un hilo está "dormido" por cualquier motivo el sistema no le dará tiempo de procesador con lo cual da la posibilidad de ejecutar otros que si lo necesitan. En el ejemplo anterior lo único que se hace entre espera y espera es ver si se ha precionado una tecla por lo tanto nos podemos tomar la libertad de dormir un poco ( 1/2 segundo ) antes de verificar el teclado y como el tiempo es tan corto el usuario ni se da cuenta. Si no lo hicieramos asi veríamos como aumenta el consumo de procesador con lo cual estamos ocupando sin motivos el tiempo que podría dedicarse a otro programa relentizando el performance general de la máquina.

Esta segunda solución es mejor que la primera y mas lógica, sin embargo hay casos en que ese medio segundo puede resultar mucho tiempo, especialmente si el programa ejecuta en un computador muy rápido. En esos casos no tiene sentido esperar mas de lo necesario y entonces lo que necesitamos es quedarnos dormidos y que sea el proceso paralelo el que nos despierte:

using System;

namespace paralelos
{
class Program
{
static System.Threading.ManualResetEvent EventoManual =
new System.Threading.ManualResetEvent(false);

static void Main(string[] args)
{
EventoManual.Reset(); // Nos aseguramos que no está señalizado
// Mandamos a ejecutar un proceso paralelo
System.Threading.ThreadPool.QueueUserWorkItem(Paralelo);

// Supuestamente aqui hacemos otras cosas antes de decidir esperar
// ...

Console.WriteLine("Presione cualquier tecla para interrumpir");
// Ahora viene la espera
while (true)
{
if (EventoManual.WaitOne(500)) // Dormimos solo lo necesario
{
// Si fue señalizado salimos con éxito
Console.WriteLine("El proceso paralelo ha terminado");
break;
}

if (Console.KeyAvailable) // El usuario se cansó de esperar ?
{
// No ha sido señalizado y el usuario ya no quiere esperar mas
// salimos sin éxito
Console.Read();
Console.WriteLine("El proceso paralelo no ha terminado");
break;
}
}

Console.WriteLine("\nPresione cualquier tecla para salir");
Console.ReadKey();
}

// Este es el proceso paralelo
static void Paralelo(object estado)
{
// Esto es para simular una operación que demora cinco segundos
System.Threading.Thread.Sleep(5000);
EventoManual.Set();
}
}
}


Aqui hemos quitado la variable de espera y la hemos sustituido por un evento manual. El evento tiene dos estados: señalizado y no señalizado. En su estado señalizado si llamamos al método WaitOne este retorna inmediatamente mientras que en el estado no señalizado el mismo llamado provoca que la ejecución quede bloqueada hasta que alguien ( otro proceso/hilo ) señalice al evento y desbloquee nuestra ejecución. Además el método WaitOne tiene la opción de pasarle el tiempo que queremos quedar bloqueados y si este pasa el retorna el valor false diciendo que nos desbloqueamos porque pasó el tiempo que le dijimos mientras que si fuimos desbloqueados por otro proceso retorna true.

En .NET tenemos muchas herramientas para tratar con los problemas que la concurrencia plantea, solo que es necesario saber usar los adecuados en casa caso.