En programación, la multitarea es la capacidad de ejecutar distintas rutinas de forma simultánea, con la posibilidad de comunicación y sincronización entre ellas. Dentro de la multitarea en sí, podemos distinguir dos conceptos que derivan de la misma, pero que difieren en su forma de actuar: multiproceso y multihilo (o multithread).
El multiproceso es la multitarea aplicada a distintos programas en ejecución dentro de un mismo ordenador, y aunque existe la posibilidad de comunicación y sincronización entre ellos, cada proceso está separado del resto por mecanismos de protección del propio sistema operativo, y se ejecutan en zonas o espacios de memoria completamente separados, de forma que no puedan interactuar unos con otros por error. Desde nuestro punto de vista de desarrollador, no nos debe preocupar cómo funciona realmente, ya que es el propio sistema operativo el que se encarga de gestionar todo lo necesario.
La programación multihilo, a diferencia del multiproceso, consiste en crear distintos hilos de ejecución de código dentro de un mismo programa, compartiendo el mismo espacio de memoria, además de los mecanismos de comunicación y sincronización entre ellos. Al estar todos los hilos dentro de un mismo espacio de memoria, existe un riesgo importante de interacciones entre ellos, que si no se controlan correctamente pueden llevar a errores.
Afortunadamente, el sistema operativo nos provee de una serie de mecanismos para controlar la ejecución de los hilos, y evitar las situaciones que puedan comprometer su ejecución. No obstante, somos nosotros los que debemos controlar todo esto, y prestar atención a todas las circunstancias que puedan ser conflictivas y buscar la manera de solucionarlas. Esto puede parecer sencillo, pero son tantos los factores a tener en cuenta, que muchas veces se torna muy complicado, especialmente con los actuales procesadores multicore.
En Xailer, hemos querido simplificar un poco esta tarea, añadiendo algunos mecanismos que sirven para tenerlo todo más controlado, y que evitan la necesidad de manejar directamente los recursos del sistema operativo.
Por supuesto, el propio Harbour también provee de algunos mecanismos que ayudan a la programación multihilo, pero en este documento nos centraremos en Xailer, ya que a nuestro modo de ver, es más sencillo y se ajusta mejor a toda la forma de programar en Xailer.
La forma más sencilla de crear un nuevo hilo de ejecución es usando el nuevo componente TThread
. Este componente permite crear un nuevo hilo, así como comunicarse entre el nuevo hilo y el hilo principal, y manejar la sincronización entre ambos.
La ejecución del segundo hilo se inicia con el método Run( <uCode>, … )
, donde <uCode>
puede ser un bloque de código o un puntero a una función o procedimiento, y se pueden pasar todos los parámetros que sea necesario. El método Run()
lanza ese segundo hilo y retorna inmediatamente. Como el segundo hilo puede tardar un poco en empezar, no hay ninguna garantía de que al retornar del método Run()
, el segundo hilo esté ya creado o en ejecución.
El segundo hilo puede ser controlado a través de los métodos:
Pause( [<lWait>] )
detiene la ejecución del hilo, que se queda en estado de pausa hasta que se reinicie su ejecución o sea abandonado completamente.Resume( [<lWait>] )
continúa la ejecución de un hilo que esté en estado de pausa.Stop( [<lWait>] )
abandona la ejecución del hilo. Este abandono se produce a través de una instrucción BREAK
, por lo que se si utiliza un bloque BEGIN SEQUENCE / END SEQUENCE
dentro del código del hilo, tenemos la oportunidad de mantener el control de la salida del hilo para, p.ej., liberar recursos utilizados.Quit()
abandona la ejecución del hilo inmediatamente. Esto se realiza utilizando el comando QUIT
dentro del hilo, lo que provoca el cierre completo de la VM correspondiente a este hilo, y no tendremos ninguna posibilidad de controlar la salida del mismo.
Los métodos Pause()
, Resume()
y Stop()
admiten un parámetro de tipo lógico, que indica si el método debe esperar hasta que haya sido procesado, o si por el contrario, debe retornar inmediatamente. El valor por defecto es .F.
, que significa que retorne inmediatamente.
El método Quit()
siempre espera a que el segundo hilo termine su ejecución.
Además de los anteriores métodos, la clase tiene una serie de eventos que nos mantienen informados sobre la ejecución del hilo:
OnStart( <oSender> )
se dispara justo cuando comienza la ejecución del hilo.OnEnd( <oSender> )
se produce justo cuando el hilo termina su ejecución, bien porque el código ha llegado al final o porque se ha llamado a los métodos Stop()
o Quit()
.OnPause( <oSender> )
se ejecuta cuando el hilo entra en modo de pausa, al llamar al método Pause()
.OnResume( <oSender> )
se dispara cuando el hilo sale del modo pausa, al llamar al método Resume()
.
Cuando se llama al método Run()
y se ejecuta el hilo, este hilo “recibe” las variables Application
, Screen
, Printer
y AppData
principales, y se crea una nueva variable que estará siempre disponible llamada SelfThread
, y que contiene el objeto de la clase TThread
correspondiente. Esta variable pública SelfThread
también está disponible desde el hilo principal, pero su valor siempre es Nil
. Esto puede ser útil en determinadas circunstancias para comprobar si estamos ejecutando el hilo principal (SelfThread == Nil
) o si estamos en un segundo hilo (SelfThread == <objeto>
).
Desde el segundo hilo se pueden utilizar los métodos Pause()
, Resume()
, Stop()
y Quit()
del objeto SelfThread
, que actúan sobre el propio hilo.
Muchas veces es necesario notificar al hilo principal sobre algo que ocurra en el segundo hilo, y viceversa. P.ej., desde el segundo hilo podemos ir notificando al hilo principal sobre el progreso de alguna tarea, para que el hilo principal incremente una barra de progreso que muestre al usuario el estado actual de dicha tarea.
Para enviar notificaciones disponemos de los métodos:
Notify( [<lWait>], [<nValue>], [<uData>] )
envía datos desde el segundo hilo al hilo principal.<lWait>
indica si la llamada esperará a que responda el hilo principal. Si es .T.
, entonces no retornará hasta que el hilo principal haya respondido a su evento OnNotify()
. Si es .F.
, el método retornará inmediatamente. El valor por defecto es .F.
<nValue>
es un valor numérico (entero de 32 bits) que se quiere enviar.<uData>
es cualquier dato válido en Harbour (un valor simple, una cadena, un array, un objeto, etc.).NotifyThread( [<lWait>], [<nValue>], [<uData>] )
envía datos desde el hilo principal al segundo hilo.<lWait>
indica si la llamada esperará a que responda el segundo hilo. Si es .T.
, entonces no retornará hasta que el segundo hilo haya respondido a su evento OnNotifyThread()
. Si es .F.
, el método retornará inmediatamente. El valor por defecto es .F.
<nValue>
es un valor numérico (entero de 32 bits) que se quiere enviar.<uData>
es cualquier dato válido en Harbour (un valor simple, una cadena, un array, un objeto, etc.).En el lado contrario, donde se reciben las notificaciones, se disparan los eventos:
OnNotify( <oSender>, <nValue>, <uData> )
se recibe en el hilo principal cuando el segundo hilo envía una notificación.<oSender>
es el objeto de la clase TThread
que ha disparado el evento.<nValue>
es el valor numérico (entero de 32 bits) que se ha enviado desde el método Notify()
del segundo hilo.<uData>
es el valor <uData>
enviado desde el método Notify()
del segundo hilo.OnNotifyThread( <oSender>, <nValue>, <uData> )
se recibe en el segundo hilo cuando el hilo principal envía una notificación.<oSender>
es el objeto de la clase TThread
que ha disparado el evento.<nValue>
es el valor numérico (entero de 32 bits) que se ha enviado desde el método NotifyThread()
del hilo principal.<uData>
es el valor <uData>
enviado desde el método NotifyThread()
del hilo principal.
No se puede utilizar el método Notify()
desde el hilo principal, ni el método NotifyThread()
desde el segundo hilo, poniendo <lWait>
a .T.
, ya que provocaría el bloqueo completo del programa (deadlock), debido a que el mismo hilo no podría responder a su propio evento.
El segundo hilo debe llamar con regularidad a la función ProcessMessages()
, con el fin de que pueda recibir notificaciones y eventos del hilo principal. Si no se llama nunca a ProcessMessages()
, el hilo estará completamente aislado, y no recibirá notificaciones ni responderá a las peticiones del hilo principal. De la misma forma, si el hilo principal está ejecutando un bucle o un proceso largo, también deberá llamar a ProcessMessages()
, para dar la oportunidad de procesar las notificaciones que reciba del segundo hilo.
Cuando un segundo hilo ha terminado una tarea pero tiene que seguir ejecutándose, sin terminar, en espera de alguna notificación desde el hilo principal, puede pausarse a sí mismo, llamando a SelfThread:Pause()
, y así no consumirá recursos de la CPU. Para reactivarse deberá llamar al método SelfThread:Resume()
, o deberá ser el hilo principal el que llame al método Resume()
del objeto TThread
correspondiente.
Si el segundo hilo necesita esperar un espacio de tiempo determinado, también puede llamar a ProcessMessages( <nMilisecs> )
, donde <nMilisecs>
es el tiempo en milisegundos. Si <nMilisecs>
es 0
, entonces no espera, y si es -1
esperará indefinidamente. El valor predeterminado para <nMilisecs>
es 0
. Si se utiliza ProcessMessages()
de esta forma, tampoco se consumen recursos de CPU, y no es necesario reactivar el hilo llamando a Resume()
, sino que se reactivará automáticamente al recibir cualquier notificación.
Además de enviar datos o notificaciones entre ambos lados del hilo, a veces es necesario sincronizar la ejecución de ambos, de forma que uno de ellos espere a que el otro llegue a un punto determinado o al revés, que ambos no coincidan en un determinado punto del programa.
Para hacer coincidir ambos hilos de ejecución en un punto determinado, se utilizan los métodos:
WaitSignal( [<nMilisecs>] )
espera a que se produzca una señal por parte del otro hilo. Si el otro hilo activa la señal dentro del tiempo especificado (en milisegundos), la función retorna con el valor .T.
, mientras que si se ha agotado el tiempo indicado sin recibir la señal, el valor de retorno será .F.
. Si <nMilisecs>
es 0
, entonces el método no espera, y retorna inmediatamente devolviendo los valores .T.
o .F.
dependiendo de que la señal haya sido emitida por el otro hilo o no. Si <nMilisecs>
es -1
, entonces el método esperará indefinidamente. El valor predeterminado de <nMilisecs>
es -1
.Signal()
emite una señal que recibirá el otro hilo a través del método WaitSignal()
.Si por el contrario, se quiere evitar que ambos hilos ejecuten una parte del código en el mismo momento (lo que se conoce como sección crítica), se utilizan los métodos:
Lock( [<nMilisecs>] )
intenta activar un bloqueo dentro del tiempo especificado (en milisegundos). Si se consigue el bloqueo, el método retorna .T.
, pero si no se ha conseguido dentro del tiempo indicado (porque el otro hilo tiene un bloqueo activo), entonces devuelve .F.
. Si <nMilisecs>
es 0
, entonces el método no espera, y retorna inmediatamente devolviendo los valores .T.
o .F.
dependiendo de si se ha podido efectuar el bloqueo o no. Si <nMilisecs>
es -1
, entonces el método esperará indefinidamente. El valor predeterminado de <nMilisecs>
es -1
.Unlock()
elimina el bloqueo que se ha obtenido con Lock()
.Estos dos métodos funcionan de forma muy similar el típico bloqueo de registros en los archivos DBF.
El depurador de Xailer ha sido modificado para que se puedan depurar los programas multihilo. El hilo principal se puede depurar perfectamente como hasta ahora, pero no así los segundos hilos. Cualquier punto de ruptura o llamada a Altd()
para invocar al depurador es ignorada completamente. Quizás en el futuro se amplie el soporte a los segundos hilos, pero actualmente existe esta limitación.
Por otro lado, cuando se invoca al depurador desde el hilo principal, y hay otros hilos ejecutándose, no hay ninguna garantía de que éstos se detengan o sigan ejecutándose. Lo habitual es que si esos hilos están ejecutando código PRG (pcode), se detengan también, pero no siempre será así. Lo único que sí es seguro es que si el segundo hilo lanza una notificación hacia el hilo principal, sí se detendrá su ejecución en espera de la respuesta de éste, que por otro lado está detenido por el depurador. De esta forma, el segundo hilo quedará suspendido hasta que el hilo principal responda a la notificación.
La programación multihilo implica una serie de circunstancias que no se dan en la programación lineal (condiciones de carrera, exclusión mutua, deadlocks, etc.). Estas circunstancias potencialmente peligrosas se deben evitar en todo lo posible, intentando hacer los hilos lo más independientes que se pueda, y controlando la interacción entre ellos por medio de los mecanismos creados para tal fin.
Siempre que sea posible se debe evitar compartir variables y/o miembros de objetos entre hilos. Si necesitamos modificar algún valor, y que el otro hilo conozca ese valor, lo mejor es utilizar el sistema de notificaciones a través de Notify()
y OnNotify()
. En particular, no se deben modificar nunca las propiedades de TThread
desde el segundo hilo. Asimismo, el segundo hilo recibe las variables Application
, Screen
, Printer
y AppData
, pero se deben tratar como de sólo-lectura, y no modificar ninguna de sus propiedades. Si se necesita imprimir desde el segundo hilo, es mejor crear un segundo objeto de TPrinter
.
Si necesitamos compartir alguna variable o un objeto entre el hilo principal y el segundo hilo, tendremos que crear una sección crítica que nos permita modificar esa variable desde un lado cada vez. Para esto, podemos utilizar los métodos Lock()
y Unlock()
que hemos visto anteriormente. Esto puede ser útil para, p.ej., modificar un valor de AppData
.
Como regla general, desde dentro de una sección crítica no se debe utilizar nunca otros mecanismos de sincronización y/o notificación. Hacerlo puede llevar a un deadlock que es la situación que se produce cuando ambos hilos se quedan a la espera del otro sin posibilidad de desbloquearse mutuamente, y conduciendo con ello a un bloqueo completo del programa. P.ej. supongamos que desde el hilo principal se ha llamado al método Lock()
para iniciar una sección crítica donde actualizar algunas variables. Un poco antes, el segundo hilo había llamado a Lock()
y había obtenido el bloqueo, por lo que el hilo principal está detenido en espera de conseguir el bloqueo. En estas condiciones, si el segundo hilo llama al método Notify()
, éste no retorna hasta que se ha notificado al hilo principal, pero éste está detenido esperando a Lock()
, así que no puede responder. Al final, ambos hilos se han quedado bloqueados, esperando el uno al otro indefinidamente. La mejor forma de evitar esta situación es hacer las secciones críticas lo más pequeñas y rápidas posible, y no llamar nunca a otros mecanismos de sincronización y/o notificación hasta que se ha salido de dicha sección crítica. Por otro lado, también es conveniente utilizar siempre un tiempo límite para los métodos WaitSignal()
y Lock()
, de forma que podamos abortar una tarea en el caso de que no se llegue a obtener el resultado esperado. De la misma forma, se deben usar las notificaciones sin espera, es decir, pasando el parámetro <lWait>
a .F.
, siempre que sea posible.
Se debe evitar en todo lo posible el uso de distintos mecanismos de sincronización al mismo tiempo. Su uso simultáneo puede llevar fácilmente a situaciones de deadlock. P.ej., cuando un hilo llama a su método WaitSignal()
para esperar al otro hilo, si este segundo hilo lanza una notificación con Notify()
, se producirá un deadlock, ya que el primer hilo no podrá responder a la notificación debido a que está esperando una señal.
Cuando desde un hilo se ejecuta un método de un objeto del otro hilo, se estará ejecutando dentro del espacio de ejecución del primero, por lo que se van a producir efectos seguramente indeseados. Por eso, nunca se debe ejecutar código de otro hilo.
La interfaz gráfica no se puede utilizar desde un segundo hilo. Siempre se debe utilizar desde el hilo principal. Cuando un segundo hilo necesita mostrar algo al usuario, debe notificarlo al hilo principal, y éste será el encargado de mostrar lo que sea necesario al usuario. Un caso típico sería incrementar una barra de progreso que muestre al usuario el estado actual de la tarea. Pero el segundo hilo nunca debe acceder a esa barra de progreso. En su lugar, debe utilizar el método Notify()
, que será recogido por el hilo principal a través del evento OnNotify()
y podrá incrementar la barra de progreso.