22 mayo 2009

Los Hilos de Ejecución (2)

En el artículo anterior vimos un sencillo ejemplo de cómo crear un contador utilizando un hilo de ejecución, pero hay un pequeño inconveniente que tenemos que arreglar.

Dentro de un hilo de ejecución podemos actualizar la pantalla (los formularios y componentes) siempre y cuando la actualización sea muy esporádica y el trabajo a realizar sea muy rápido (entrar y salir). Pero si durante el evento Execute de un hilo intentamos dibujar o actualizar los componentes VCL más de lo normal, puede provocar una excepción de este tipo:


También suele ocurrir si dos hilos ejecutándose en paralelo intentan actualizar el mismo componente VCL.

Esto se debe a que la librería VCL no ha sido diseñada para trabajar con problemas de concurrencia en múltiples hilos de ejecución.

SINCRONIZANDO CÓDIGO ENTRE HILOS

Para evitar este problema vamos a utilizar el procedimiento Synchronice que permite que un hilo llame a un procedimiento definido dentro del mismo pero en el contexto del hilo primario, es decir, el hilo principal de nuestro programa ejecuta el procedimiento que el hilo le manda como parámetro utilizando Synchronice. Es como si el hilo le dijera al hilo principal: ejecútame esto que a mi me da la risa.

De este modo ya podemos manipular los componentes VCL sin preocupación.

Modificamos la definición de la clase:

type
TContador = class(TThread)
dwTiempo: DWord;
iSegundos: Integer;
Etiqueta: TLabel;
constructor Create; reintroduce; overload;
procedure Execute; override;
procedure ActualizarPantalla;
end;

Y la implementación de ActualizarPantalla es la siguiente:

procedure TContador.ActualizarPantalla;
begin
Etiqueta.Caption := IntToStr(iSegundos);
end;

Entonces sólo hay que sincronizar el hilo con la VCL en el procedimiento Execute:

procedure TContador.Execute;
begin
inherited;
OnTerminate := Terminar;

// Contamos hasta 10 segundos
while (iSegundos < 10) and not Terminated do
begin
// ¿Han pasado 1000 milisegundos?
if GetTickCount - dwTiempo > 1000 then
begin
// Incrementamos el contador de segundos y
// actualizamos la etiqueta
Inc(iSegundos);
Synchronize(ActualizarPantalla);
dwTiempo := GetTickCount;
end;
end;
end;

Llamamos al procedimiento ActualizarPantalla utilizando Synchronize. Y no sólo podemos utilizar este método para sincronizarnos con los componentes VCL sino que también con variables globales.

Ahora vamos otro caso de dos hilos incrementando paralelamente una misma variable que hace de contador. Tenemos este formulario:

Tenemos dos listas (TListBox) que nos van a mostrar el valor de la variable global i que van a incrementar dos hilos a la vez cuya clase es la siguiente:

type
THilo = class(TThread)
Lista: TListBox;
procedure Execute; override;
procedure MostrarContador;
end;

Y esta sería su implementación:

{ THilo }

procedure THilo.Execute;
begin
inherited;
FreeOnTerminate := True;
while not Terminated do
begin
i := i + 1;
Synchronize(MostrarContador);
Sleep(1000);
end;
end;

El procedimiento Execute incrementa la variable entera global i, la muestra por pantalla en su lista correspondiente y espera un segundo. Luego tenemos el procedimiento que muestra el valor de la variable i según el hilo:

procedure THilo.MostrarContador;
begin
Lista.Items.Add('i='+IntToStr(i));
end;

Cuando pulsemos el botón Comenzar, ponemos el contador i a cero, creamos los dos hilos, les asignamos su lista correspondiente y los ponemos en marcha:

procedure TFDosHilos.BComenzarClick(Sender: TObject);
begin
i := 0;
Hilo1 := THilo.Create(False);
Hilo2 := THilo.Create(False);
Hilo1.Lista := Lista1;
Hilo2.Lista := Lista2;
Hilo1.Resume;
Hilo2.Resume;
end;

En cualquier momento podemos detener la ejecución de ambos hilos pulsando el botón Detener:

procedure TFDosHilos.BDetenerClick(Sender: TObject);
begin
Hilo1.Terminate;
Hilo2.Terminate;
end;

Al ejecutarlo conforme está ahora mismo este sería el resultado:


Aquí hay algo que no encaja. Si los dos hilos incrementan una vez la variable i sólo deberían verse números pares o todo caso el valor de un hilo debería ser distinto al del otro, pero eso es lo que pasa cuando no van sincronizados los incrementos de la variable i.

Sin tocar el código fuente, vuelvo a ejecutar el programa y me da este otro resultado:


¿Qué está pasando? Pues que el hilo 2 puede incrementar la variable i justo después de que la incremente el hilo 1 o bien pueden hacerlo los dos a la vez, provocando un retraso en el contador.

Esto podemos solucionarlo sincronizando el incremento de la variable i dentro de un nuevo procedimiento que podemos añadir a la clase THilo:

THilo = class(TThread)
Lista: TListBox;
procedure Execute; override;
procedure MostrarContador;
procedure IncrementarContador;
end;

El procedimiento IncrementarContador sólo hace esto:

procedure THilo.IncrementarContador;
begin
i := i + 1;
end;

Luego hacemos que el procedimiento Execute sincronice el incremento de la variable i:

procedure THilo.Execute;
begin
inherited;
FreeOnTerminate := True;
while not Terminated do
begin
Synchronize(IncrementarContador);
Synchronize(MostrarContador);
Sleep(1000);
end;
end;

Este sería el resultado al ejecutarlo:


Vemos ahora que los incrementos de la variable i son de dos en dos y evitamos que un hilo estropee el incremento de otro. Aunque esto no soluciona a la perfección la sincronización ya que si por ejemplo tenemos otra aplicación (como el navegador Firefox) que ralentiza el funcionamiento de Windows, podría provocar que se relentice uno de los hilos respecto al otro y provoque de nuevo la descoordinación en los incrementos. Eso veremos como solucionarlo en el siguiente artículo mediante semáforos y eventos.

PARAR, REANUDAR, DETENER O ESPERAR LA EJECUCIÓN DEL HILO

Siguiendo con el primer ejemplo (la clase TContador), una vez el hilo está en marcha podemos suspenderlo (manteniendo su estado) utilizando el método Suspend:

Contador.Suspend;

Para que continúe sólo hay que volver a llamar al procedimiento Resume:

Contador.Resume;

En cualquier momento podemos saber si está suspendido el hilo mediante la propiedad:

if contador.Suspended then
...

Si queremos detener definitivamente la ejecución del hilo entonces deberíamos añadirle al bucle principal de nuestro procedimiento Execute esta comprobación:

// Contamos hasta 10 segundos
while (iSegundos < 10) and not Terminated do
begin
...

Entonces podíamos añadir al formulario un botón llamado Detener que al pulsarlo haga esto:

Contador.Terminate;

Lo que hace realmente el procedimiento Terminate no es terminar el hilo de ejecución, sino poner la propiedad Terminated a True para que nosotros salgamos lo antes posible del bucle infinito en el que está sumergido nuestro hilo. Lo que nunca hay que intentar es hacer esto para terminar un hilo:

Contador.Free;

Lo que es terminar, seguro que termina, pero la explosión la tenemos asegurada. Para cerrar el hilo inmediatamente podemos llamar a una función de la API de Windows llamada TerminateThread:

TerminateThread(Contador.Handle, 0);

El primer parámetro es el Handle del hilo que queremos detener y el según parámetro es el código de salida que puede ser leído posteriormente por la función GetExitCodeThread. Recomiendo no utilizar la función TerminateThread a menos que sea estrictamente necesario. Es mejor utilizar el procedimiento Terminate y procurar salir limpiamente del bucle del hilo cerrando los asuntos que tengamos a medio (cerrando ficheros abiertos, liberando memoria de objetos creados, etc.).

Por otro lado tenemos el procedimiento DoTerminate que no termina el hilo de ejecución, sino que provoca el evento OnTerminate en el cual podemos colocar todo lo que necesitamos para terminar nuestro hilo. Por ejemplo, podemos poner al comienzo de nuestro procedimiento Execute:

procedure TContador.Execute;
begin
inherited;
OnTerminate := Terminar;
...

Y luego definimos dentro de nuestra clase TContador el evento Terminar:

procedure TContador.Terminar(Sender: TObject);
begin
// Escribimos aquí el código que necesitamos
// para liberar los recursos del hilo y luego
// le mandamos una señal para que termine el bucle principal
Terminate;
end;

También podemos hacer que el hilo principal de la aplicación espere a que termine la ejecución de un hilo antes de seguir ejecutando código. Esto se consigue con el procedimiento WaitFor:

Contador.WaitFor;

Aunque esto puede ser algo peligroso porque no permite que Windows procese los mensajes de nuestra ventana. Podría utilizarse por ejemplo para ejecutar un programa externo (de MSDOS) y esperar a que termine su ejecución.

En el siguiente artículo veremos como podemos controlar las esperas entre el hilo principal y los hilos secundarios para que se coordinen en sus trabajos.

Pruebas realizadas en RAD Studio 2007.

Publicidad