Tabla de Contenidos

Multitarea en Xailer

Introducción

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.

El soporte de programación multihilo ha sido introducido en Xailer 3.1.

En versiones anteriores de Xailer que utilizan Harbour como compilador, se puede utilizar también multihilo, pero utilizando las funciones propias de Harbour, y con muchas limitaciones, ya que Xailer no realiza ningún control sobre otros hilos y toda esa tarea recae sobre el programador.

Versiones de Xailer más antiguas, que utilizaban xHarbour, sólo pueden hacer uso de la programación multihilo a nivel C, de forma muy sencilla y limitada, ya que aunque el propio xHarbour soportaba multihilo, tenía algunos problemas que lo hacían muy inestable y poco fiable.
Para utilizar la programación multihilo en Xailer, debe enlazar la librería hbvmmt en su proyecto. Para ello, deberá ir a las propiedades del proyecto, y dentro del apartado librerías, tendrá que desmarcar la librería hbvm y marcar la librería hbvmmt.

Cómo crear un hilo

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:

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:

El segundo hilo

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.

Comunicación entre el hilo principal y el segundo 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:

En el lado contrario, donde se reciben las notificaciones, se disparan los eventos:

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.

Sincronización entre el hilo principal y el segundo hilo

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:

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:

Estos dos métodos funcionan de forma muy similar el típico bloqueo de registros en los archivos DBF.

El depurador en los programas multihilo

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.

Consideraciones de la programación multihilo

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.