08 mayo 2009

Los Hilos de Ejecución (1)

Hace tiempo escribí un pequeño post sobre como crear hilos de ejecución:

Cómo crear un hilo de ejecución

Pero debido a que últimamente los procesadores multinúcleo van tomando más relevancia y que algunos lectores de este blog han pedido que me extienda en el tema, voy a meterme de lleno en el apasionante y peligroso mundo de las aplicaciones multihilo.

Cuando manejamos Windows creemos que es un sistema operativo robusto que puede ejecutar gran cantidad de tareas a la vez. Eso es lo que parece externamente, pero realmente sólo tiene dos hilos de ejecución en los últimos procesadores de doble núcleo. Y en procesadores Pentium 4 e inferiores sólo contiene un solo hilo de ejecución (dos mediante el disimulado HyperThreading que tantos problemas nos dio con Interbase 6).

Entonces, ¿Cómo puede ejecutar varias tareas a la vez? (Emule, Bittorrent, MSN, reproductor multimedia, etc.). Primero tenemos que ver lo que significa un proceso.

LOS PROCESOS

Un proceso (un programa EXE o un servicio de Windows) contiene como mínimo un hilo, llamado hilo primario. Si queremos que nuestro proceso tenga más hilos, se lo tenemos que pedir al sistema operativo. Estos nuevos hilos pueden ejecutarse paralelamente al hilo primario y ser independientes.

Un proceso se compone principalmente de estas tres áreas:

AREA DE CÓDIGO: Es una zona de memoria de sólo lectura donde está compilado nuestro programa en código objeto (binario).

HEAP (MONTÓN): Es en esta zona de memoria de lectura/escritura donde se suelen guardar las variables globales que creamos así como las instancias de los objetos que se van creando en memoria.

PILA: Aquí se guardan temporalmente los parámetros que se pasan entre funciones así como las direcciones de llamada y retorno de procedimientos y funciones. Todos los datos que se introducen en la pila tienen que salir mediante el método LIFO (lo último que entra es lo primero que tiene que salir) porque si no provoca un desbordamiento del puntero del procesador que hace que retorne a otra zona de memoria provocando el error Access Violation 000000 o FFFFFF, es decir, intenta salirse del segmento de memoria de nuestra aplicación.

Estas tres zonas se encuentran herméticamente separadas las unas de las otras y si por error un comando de nuestro programa intenta acceder fuera del heap o de la pila provocará el conocido mensaje que tanto nos gusta: Access Violation. Suele ocurrir al intentar acceder a un objeto que no ha sido creado en el heap (dirección 000000 = nil), etc.

El conjunto de estas tres zonas de memoria es el proceso. Ahora bien, si creamos un nuevo hilo de ejecución dentro del proceso, éste tendrá su propia pila, aunque compartirá la misma zona de datos (heap).

EL PROCESO MULTITAREA

Windows realiza la multitarea cediendo un pequeño tiempo de procesador a cada proceso, de modo que en un solo segundo pueden ejecutarse ciertos de procesos simultáneos. Para los viejos roqueros que estudiamos ensamblador vimos que antes de cambiar de tarea Windows guarda el estado de todos los registros en la pila:

PUSH EAX
PUSH EBX
...

Para luego restaurarlos y seguir su marcha:

...
POP EBX
POP EAX

No está de más adquirir unos pequeños conocimientos de ensamblados de x86 para conocer a fondo las tripas de la máquina.

Cuando Windows para el control de un proceso, memoriza el estado de los registros del procesador y la pila y pasa al siguiente proceso hasta que finalice su tiempo. Realmente, este cambio de procesos no lo realiza Windows, sino el mismo procesador que trae esta característica por hardware (desde el 80386. Hasta el procesador 80286 tenía este modo multitarea aunque sólo en 16 bits).

Pero lo que vamos a ver es este artículo es un cambio de ejecución entre hilos del mismo proceso, algo que consume muchos menos recursos que el cambio entre procesos.

Por ejemplo, los navegadores webs modernos actuales como Firefox o Chrome ejecutan cada web dentro del mismo proceso pero en hilos diferentes (incluso Chrome lo hace en distintos procesos), de modo que si una página web se queda colgada no afecta a otra situada en la siguiente pestaña (lo que no se es si llamarán al sistema de hilos de Windows y tendrán su propio núcleo duro de ejecución, su propio sistema multitarea).

Pero programar hilos de ejecución no está exento de problemas. ¿Qué pasaría si dos hilos acceden a la vez a la misma variable de memoria? O peor aún, ¿Y si intentan escribir a la vez en una unidad de CD-ROM? Las excepciones que pueden ocurrir pueden ser catastróficas, aunque afortunadamente a partir de Windows NT y XP se controlan muy bien todos estos problemas de concurrencia.

Lo que si es realmente difícil es intentar depurar dos hilos de ejecución que utilizan los mismos recursos del proceso. De ahí a que sólo hay que recurrir a los hilos en casos estrictamente necesarios.

LA CLASE TTHREAD

Toda la complejidad de los hilos de ejecución que se programan en la API de Windows quedan encapsulados en Delphi en esta sencilla clase. Para crear un hilo de ejecución basta heredar de la clase TThread y sobrecargar el método Execute:

type
THilo = class(TThread)
procedure Execute; override;
end;

En la implementación del procedimiento Execute es donde hay que introducir el código que queremos ejecutar cuando arranque nuestro hilo de ejecución:

{ THilo }

procedure THilo.Execute;
begin
inherited;

end;

Veamos un ejemplo sencillo de un hilo de ejecución con un contador de 10 segundos.

CREANDO UN CONTADOR DE SEGUNDOS

Voy a crear un nuevo proyecto con un formulario en el que sólo va a haber una etiqueta (TLabel) llamada EContador con el contador de segundos:


A este formulario lo he llamado FPrincipal. En la interfaz del mismo voy a definir la clase TContador que va a heredar de un hilo TThread:

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

He sobrecargado el constructor para que inicialice mis contadores de tiempo y segundos:

constructor TContador.Create;
begin
inherited Create(True); // llamamos al constructor del padre (TThread)
dwTiempo := GetTickCount;
iSegundos := 0;
end;

La función GetTickCount nos devuelve un número entero que representa el número de milisegundos que han pasado desde que encendimos nuestro PC. Si queréis más precisión podéis utilizar la función TimeGetTime declarada en la unidad MMSystem (sobre todo si vais a programar videojuegos).

Siguiendo con la implementación de nuestra clase TContador, la variable dwTiempo la voy a utilizar para controlar el número de milisegundos que van pasando desde que ejecutamos el hilo. Y la variable iSegundos es un contador de segundos de 0 a 10. También he añadido una referencia a una Etiqueta de tipo TLabel que se la daremos al crear una instancia del hilo en el evento OnCreate del formulario principal:

procedure TFPrincipal.FormCreate(Sender: TObject);
begin
Contador := TContador.Create(True);
Contador.Etiqueta := EContador;
Contador.FreeOnTerminate := True;
Contador.Resume;
end;

Después de crear el hilo le pasamos la etiqueta que tiene que actualizar y le decimos mediante la propiedad FreeOnTerminate que se libere de memoria automáticamente al terminar la ejecución del hilo.

Después llamamos al método Resume que lo que hace internamente es ejecutar el procedimiento Execute de la clase TThread que tendrá este código:

procedure TContador.Execute;
begin
inherited;

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

Creamos un bucle cerrado que va contando de 1000 en 1000 milisegundos e incrementando el contador de segundos. Cuando se salga de este bucle se terminará la ejecución del hilo automáticamente. También se podía haber utilizado procedimiento Sleep, pero nunca me ha gustado mucho esta función porque miestras se está ejecutando no podemos hacer absolutamente nada.

Eso ocurrirá al ejecutar el programa y cuando llegue el contador a 10:


Podemos ver como va la ejecución del hilo en la ventana inferior de Delphi si estamos ejecutando el programa en modo depuración:


Como puede verse en las líneas en rojo, el hilo que hemos creado tiene el ID 3736 que no tiene nada que ver con el hilo primario:


En el siguiente artículo seguiremos profundizando un poco más en los hilos de ejecución a través de otros ejemplos.

Pruebas realizadas en RAD Studio 2007.

Publicidad