Programación con Hebras


Introducción



Una aplicación multihebra es una aplicación que contiene varías vías simultáneas de ejecución.

Consideraciones:

¿Por qué usar Hebras?

El uso de hebras (de paralelismo, en general) proporciona una serie de ventajas frente a las limitaciones de los sistemas monotarea.

Ejemplo:
Supongamos que nuestra aplicación tiene que ocuparse de la realización de copias de seguridad de los datos con los que trabaja. Con una única hebra tendríamos que programar las copias de seguridad fuera del horario habitual de trabajo (¿y si tiene que funcionar las 24 horas del día?). Con varias hebras, podemos aprovechar los períodos de inactividad del sistema.

Aprovechamiento de los recursos del sistema

Cuando se utiliza una sola hebra, el programa debe detener completamente la ejecución mientras espera a que se realice cada tarea. La CPU permanece ocupada completamente (o inactiva) hasta que el proceso actual termine. Si se utilizan varias hebras, el sistema puede usarse para realizar varias tareas simultáneamente (vg: reproducción de MP3s en background).

Establecimiento de prioridades

Como es lógico, se le asigna mayor prioridad a las tareas más importantes (vg: las que requieran una respuesta más rápida).

Multiprocesamiento real

En un sistema multiprocesador, si la aplicación se descompone en varias hebras, el sistema operativo podrá asignar cada una a una de las CPUs del sistema.

Estructuración: Paralelismo implícito

En muchas ocasiones, un programa puede diseñarse como varios procesos paralelos que funcionen de forma independiente. La descomposición de una aplicación en varias hebras puede resultar muy beneficiosa en términos de desacoplamiento de código (tareas independientes se implementan por separado) y de calidad del diseño (frente a futuras ampliaciones, por ejemplo).

IMPORTANTE: El objetivo principal del uso de hebras es mejorar el rendimiento del sistema. El diseñador/programador deberá decidir hasta qué punto deben utilizarse.


La clase TThread



C++ Builder proporciona la clase abstracta TThread para la creación de hebras.

Para definir una hebra se debe crear un descendiente de TThread, para lo cual se ha de crear una clase derivada de TThread. Para facilitar el trabajo, C++Builder incluye un asistente que lo hace por nosotros.

Ejemplo: Crear una hebra

Abrimos el repositorio en su página New (al que se accede desde la opción del menú File->New...). Seleccionamos el elemento Thread Object. Denominamos THebraPelota a la clase derivada de TThread. Ya hemos creado nuestra primera hebra en una unidad independiente (que almacenaremos en los ficheros HPelota.cpp y HPelota.h).

La clase TThread es abstracta porque posee un método virtual puro denominado Execute(), que será el que rellenemos con el código asociado a la hebra.

Como sucede con cualquier otra clase, en una clase derivada de TThread se pueden añadir todos los miembros (propiedades y métodos) que se necesiten. Obligatoriamente sólo hay que implementar:

Inicialización de las hebras

La inicialización de una hebra se realiza en su constructor, donde se establecen los valores iniciales de cuantas propiedades sean necesarias.

Antes de invocar al constructor de una clase derivada de TThread, se invoca al constructor de su clase base (TThread):

__fastcall TThread(bool CreateSuspended);

Éste recibe como parámetro false si deseamos que la hebra se ejecute inmediatamente después de ser creada o true si queremos que quede suspendida inicialmente.

Dos características de las hebras conviene establecerlas en el constructor: su prioridad y cuándo debe liberarse la hebra.

Asignación de una Prioridad por Defecto

La prioridad de una hebra indica qué grado de preferencia tiene la hebra cuando el sistema operativo reparte el tiempo de CPU entre las distintas hebras que se estén ejecutando en el sistema.

Para indicar la prioridad del objeto de hebra hay que fijar el valor de la propiedad Priority:

Valor Prioridad
tpIdle Se ejecuta cuando el sistema está inactivo.
tpLowest Dos puntos por debajo del valor normal.
tpLower Un punto por debajo del valor normal.
tpNormal La hebra tiene la prioridad normal.
tpHigher Un punto por encima del valor normal.
tpHighest Dos puntos por encima del valor normal.
tpTimeCritical La hebra tiene la prioridad más alta.

Si se aumenta mucho la prioridad de una hebra que requiera muchos recursos (vg: CPU) puede ocurrir que las demás hebras de la aplicación (y del sistema) no lleguen a procesarse a la velocidad adecuada.

Ejemplo:

Si tenemos un proceso que consume una cantidad considerable de tiempo de CPU, puede ser interesante dar la posibilidad al usuario de cancelar el proceso durante su ejecución. Esto se puede conseguir creando una hebra con prioridad normal para ese proceso y otra hebra de alta prioridad que únicamente esté a la espera de que el usuario pulse un botón de cancelación. La hebra de alta prioridad no consumirá tiempo de CPU porque estará bloqueada pero, en el caso de que el usuario pulse el botón, responderá rápidamente.

Liberación de las hebras

A menudo las hebras de una aplicación se ejecutan una sola vez. Si este es el caso, lo más sencillo es dejar que el objeto hebra se libere a sí mismo, estableciendo la propiedad FreeOnTerminate a true.

Sin embargo, hay casos en que el objeto hebra representa una tarea que debe realizarse varias veces. Si la aplicación requiere varias instancias del mismo objeto hebra (para ejecutar la hebra varias veces), se puede aumentar el rendimiento de la aplicación almacenando las hebras en una caché para utilizarlas más adelante, en lugar de destruirlas y crearlas de nuevo cada vez. Para ello, basta con fijar el valor de la propiedad FreeOnTerminate a false. En este caso, será responsabilidad del programador la liberación de la hebra.

MUY IMPORTANTE: Antes de liberar una hebra hay que asegurarse de que no se esté ejecutando (ya veremos cómo).

Ejemplo:

  • En HPelota.h:
    #include "ObjGraf.h"
    
    En la clase THebraPelota:
    ...
    public:
      TPelota *Pelota;
      __fastcall THebraPelota(TPaintBox *PaintBox);
      __fastcall ~THebraPelota(void);
    ...
    
  • En HPelota.cpp:
    #include <stdlib.h>
    
    ...
    
    __fastcall THebraPelota::THebraPelota 
             (TPaintBox *PaintBox)
             : TThread(false)
    {
      Priority        = tpNormal;
      FreeOnTerminate = true;
    
      Pelota = new TPelota (   // Objeto pelota...
        PaintBox,              // ...aletorio.
        (TColor)(random(0xFF)        |
                (random(0xFF)*0x100) |
                (random(0xFF)*0x10000)),
        random(PaintBox->Width),
        random(PaintBox->Height),
        random(20)+1,
        TDireccion((random(2)+1) | 
                   (random(2)+1)*4));
    }
    
    __fastcall THebraPelota::~THebraPelota(void)
    {
      delete Pelota;
    }
    

  • Ejecución de las hebras



    El método Execute es la función principal de una hebra. Una hebra puede concebirse como un programa que se ejecuta dentro de la aplicación. Una hebra no es un programa independiente porque comparte el mismo espacio con las demás hebras de la aplicación. Por tanto, es necesario asegurarse de no escribir en la memoria que utilizan las otras hebras. Por otro lado, puede utilizarse la memoria compartida entre las hebras para establecer una comunicación entre ellas.

    Ejemplo:

  • En HPelota.cpp:
    void __fastcall THebraPelota::Execute()
    {
      while (true) {     // Código de la Hebra.
        Pelota->Mover();
        Sleep(100);
      }
    }
    
  • Variables locales a las hebras

    Con la palabra clave __thread podemos definir variables globales que son locales a una hebra. Habrá una copia de la variable para cada una de las hebras que se creen: la variable es global para las funciones de una hebra pero no se comparte con otras hebras.

    Ejemplo:

    
    int __thread x;
    

    Las variables __thread han de ser estáticas (no pueden ser punteros) y no pueden inicializarse en tiempo de ejecución).

    No es aconsejable utilizar este tipo de variables, ya que se puede conseguir lo mismo añadiendo una variable de instancia a la clase de la hebra.

    Control de la ejecución de una hebra

    Una hebra puede encontrarse en dos estados:

    Las hebras pueden iniciarse y suspenderse tantas veces como sea necesario antes de que finalice su ejecución. Para detener una hebra temporalmente hay que realizar una llamada al método Suspend(). Cuando resulte conveniente reanudar la ejecución de la hebra se debe realizar una llamada al método Resume(). Suspend() y Resume() utilizan un contador interno, de forma que es posible anidar las llamadas a Suspend() y Resume(). La hebra no reanuda su ejecución hasta que todas las suspensiones hayan tenido su correspondiente llamada a Resume().

    La ejecución de una hebra termina normalmente cuando termina la ejecución del método Execute().

    Para hacer que la ejecución de una hebra finalice de forma prematura hay realizar una llamada al método Terminate(). Terminate() cambia el valor de la propiedad Terminated a true, indicando así que la hebra ha de finalizar su ejecución en cuanto sea posible. Por tanto, el método Execute() ha de comprobar periódicamente el valor de la propiedad Terminated y detener la ejecución de la hebra cuando esté a true.

    Ejemplo:

  • En HPelota.cpp:
    void __fastcall THebraPelota::Execute()
    {
      while (!Terminated) {     // Código de la Hebra.
        Pelota->Mover();
        Sleep(100);
      }
    }
    
  • Cuando se crea una hebra, el estado inicial de ésta dependerá del parámetro que se le pase a su constructor: ejecutándose (false) o suspendida (true). Si se crea una hebra inicialmente suspendida se ha de invocar al método Resume() para comenzar su ejecución.

    Ejemplo:

  • En Ppal.h:
    Para usar la unidad HPelota desde un formulario:
    #include "HPelota.h"
    ...
    private:	// User declarations
      THebraPelota **Objs;
    ...
    
  • En Ppal.cpp:
    #include <stdlib.h>
    
    ...
    
    void __fastcall TPpalFrm::FormCreate(TObject *Sender)
    {
      Objs = new THebraPelota*[4];
    
      randomize();
    
      for (int i=0; i<4; ++i)
          Objs[i] = new THebraPelota (PaintBox);
    }
    
    void __fastcall TPpalFrm::FormDestroy(TObject *Sender)
    {
      for (int i=0; i<4; ++i)
          Objs[i]->Terminate();
    
      Sleep(3000); // Esperar tres segundos...
                   // ...a que se liberen todas las hebras
      delete[] Objs;
    }
    

    Nota: La sentencia Sleep(3000), es una chapuza para evitar que se “cuelgue” la aplicación al liberar las hebras (más adelante veremos como solucionar este problema correctamente).


  • Coordinación entre hebras



    El código ejecutado por una hebra debe tener en cuenta la posible existencia de otras hebras que se ejecuten concurrentemente. Hay que tener cuidado para evitar que dos hebras accedan a un recurso compartido (vg: objeto o variable global) al mismo tiempo. Además, la ejecución de una hebra puede depender del resultado de las tareas que realicen en otras hebras. Por tanto, las distintas hebras de una aplicación han de coordinar su ejecución.

    Uso de recursos compartidos

    Cuando varias hebras comparten el uso de un recurso, pueden ocurrir situaciones no deseadas si dos o más hebras acceden (o intentan acceder) al mismo recurso simultáneamente.

    Para evitar conflictos con otras hebras, puede que se necesite bloquear la ejecución de otras hebras al acceder a objetos o variables compartidas. Hay que tener cuidado de no bloquear innecesariamente la ejecución de otras hebras (para no disminuir el rendimiento de la aplicación).

    Ejemplo:

    Si ejecutamos el ejemplo anterior, aunque el funcionamiento de la aplicación es correcto, la visualización en el PaintBox sufre algunos errores de forma aleatoria. Esto se debe a que los métodos Mostrar() y Borrar() de las distintas hebras se entrelazan en el acceso al Canvas del PaintBox. ¡Se ha de evitar el acceso simultáneo de las hebras al componente Canvas del PaintBox!.

    La hebra principal de la VCL

    No existe garantía alguna (en general) de que los métodos de los componentes de C++ Builder funcionen correctamente cuando son compartidos por varias hebras. Es decir, al acceder a propiedades o ejecutar métodos pueden efectuarse algunas operaciones que utilicen memoria no protegida de las acciones de otras hebras.

    Por este motivo se reserva una hebra, la “hebra principal de la VCL”, para el acceso a los objetos de la VCL. Ésta es la hebra que gestiona todos los mensajes de Windows que reciben los componentes de la aplicación.

    Si todos los objetos acceden a sus propiedades e invocan a sus métodos dentro de una única hebra, no hay por qué preocuparse. Para usar la hebra VCL principal hay que crear un método que realice las acciones necesarias y llamarlo utilizando el método Synchronize().

    Ejemplo:

  • En ObjGraf.h y en ObjGraf.cpp:
    El método Synchronize() recibe cómo parámetro un método del tipo void __fastcall <método> (void), por lo que hay que cambiar la definición del método Mover() de TPelota:
    void __fastcall Mover (void);
    
  • En HPelota.cpp:
    void __fastcall THebraPelota::Execute()
    {
    
      while (!Terminated) { 
    
            Synchronize(Pelota->Mover);
     
            Sleep(100);
      }
    }
    
  • No siempre es necesario utilizar la hebra VCL principal (de hecho, en algunos casos no se puede usar y tendremos que emplear otros mecanismos como las secciones críticas). Algunos componentes están preparados para funcionar con hebras (son thread-safe) y no hay necesidad de usar el método Synchronize(). Esto permite aumentar el rendimiento de la aplicación porque no es necesario esperar a que la hebra VCL principal entre en su bucle de mensajes (y, además, no bloqueamos esta hebra). Los componentes de acceso a datos funcionan correctamente con hebras si pertenecen a distintas sesiones (con la excepción de los controladores de Access, que utilizan la biblioteca ADO).

  • Los objetos gráficos funcionan correctamente con hebras. No se necesita utilizar la hebra VCL principal acceder a TFont, TPen, TBrush, TBitmap, TMetafile ni TIcon. Para compartir el uso de objetos lienzo (TCanvas y descendientes) hay que bloquearlos antes.

  • Algunos componentes (por ejemplo, TList) tienen una versión adecuada para funcionar con varias hebras (TThreadList).

    Bloqueo de objetos

    Algunos componentes cuentan con mecanismos de bloqueo para que puedan compartirse por varias hebras. Por ejemplo, los objetos de tipo lienzo (TCanvas y sus descendientes) cuentan con un método Lock() que impide a las otras hebras acceder al objeto hasta que se realiza una llamada al método Unlock(). En el caso de TThreadList, una llamada a TThreadList::LockList() devuelve el objeto de tipo TList asociado e impide que otras hebras accedan a la lista hasta que se realice una llamada a UnlockList(). Las llamadas a los métodos TCanvas::Lock o TThreadList::LockList pueden anidarse con seguridad. El desbloqueo no se produce hasta que se haya realizado una llamada de desbloqueo por cada llamada de bloqueo.

    Ejemplo:

  • En HPelota.cpp:
    Eliminar la llamada al método Synchronize() realizada desde el método THebraPelota::Execute().
  • En ObjGraf.cpp:
    Tanto en TPelota::Mostrar() como en TPelota::Borrar():
    PaintBox->Canvas->Lock();
    
    ...
    
    PaintBox->Canvas->Unlock();
    
  • Secciones críticas

    Si un objeto no cuenta con mecanismos de bloqueo incorporados, siempre se puede utilizar una sección crítica. Las secciones críticas funcionan como puertas que permiten el paso de una sola hebra cada vez. Para utilizar una sección crítica hay que crear una instancia global de TCriticalSection. TCriticalSection cuenta con dos métodos: Acquire() (que impide a otras hebras acceder a la sección crítica) y Release() (que elimina el bloqueo).

    Cada sección crítica se asocia a un recurso que se desea compartir por varias hebras. Todas las hebras que accedan a un mismo recurso deberán utilizar el método Acquire() de la correspondiente sección crítica para asegurarse de que el recurso no está siendo utilizado por ninguna otra hebra. Al finalizar sus operaciones, las hebras tienen que realizar una llamada al método Release() para que las demás hebras puedan acceder al recurso invocando al método Acquire(). Si se omite la llamada a Release() el recurso quedaría bloqueado para siempre.

    Ejemplo:

  • En ObjGraf.cpp:
    #include <syncobjs.hpp>
    
    TCriticalSection *SC = new TCriticalSection();
    
    // ¡¡¡Habría que destruirla!!!
    
    ...
    
    En TPelota::Mostrar() y en TPelota::Borrar(), eliminar el bloqueo del objeto de tipo TCanvas y emplear en su lugar la sección crítica:
    {
    
      SC->Acquire();
    
      ...
    
      SC->Release();
    
    }
    
  • Sincronización entre hebras

    Puede que las distintas hebras de una aplicación realicen tareas que no sean completamente independientes, por lo cual será necesario que en ciertas ocasiones una hebra espere a que otra termine la ejecución de una acción determinada.

    Ejemplo:

    Tenemos que dos hebras que calculan el total de ingresos y el total de gastos de una compañía respectivamente. Una tercera hebra ha de obtener el balance final, por lo que deberá esperar a que las dos hebras anteriores terminen su trabajo.

    Esperar la finalización de una hebra

    Para esperar a que finalice la ejecución de otra hebra, se puede utilizar el método WaitFor() de la otra hebra. WaitFor() bloquea la ejecución de la hebra actual hasta que la otra hebra se haya cerrado, ya sea por haber finalizando la ejecución de su método Execute() o por haberse producido una excepción.

    WaitFor() devuelve un valor entero que es el valor de la propiedad ReturnValue de la hebra. La propiedad ReturnValue puede cambiarse en el método Execute() y su significado se lo daremos nosotros.

    Ejemplo:

  • En Ppal.cpp:
    void __fastcall TPpalFrm::FormDestroy(TObject *Sender)
    {
      int i;
    
      for (i=0; i<4; i++)
          Objs[i]->Terminate();
    
      for (i=0; i<4; i++)
          Objs[i]->WaitFor();
    
      delete[] Objs;
    }
    
  • Esperar a que se complete una tarea: Sucesos

    En algunas ocasiones es necesario esperar a que otra hebra haya realizado una tarea determinada sin que ello implique que haya finalizado su ejecución. En estos casos hay que utilizar un objeto de tipo suceso (TEvent).

    Cuando una hebra completa una operación de la que dependen otras hebras, realiza una llamada a TEvent::SetEvent(). SetEvent() activa una señal que cualquier otra hebra puede detectar invocando al método TEvent::WaitFor(). Para desactivar esa señal existe el método TEvent::ResetEvent().

    El método WaitFor() espera un tiempo determinado (establecido en milisegundos al llamar al método) hasta que se active la señal. WaitFor() puede devolver uno de los siguientes valores:

    Valor Significado
    wrSignaled La señal asociada al suceso se ha activado.
    wrTimeout Ha transcurrido el tiempo especificado sin que la señal se activase.
    wrAbandoned El objeto de tipo TEvent se destruyó antes de que se llegase al time-out.
    wrError Se ha producido un error durante la espera. El código del error concreto se puede obtener de la propiedad TEvent::LastError.

    Si se desea seguir a la espera de un suceso indefinidamente puede pasar el valor INFINITE como parámetro del método WaitFor(). Pero tenga cuidado, la hebra se quedará bloqueada para siempre si nunca llegara a producirse el evento que espera.

    NOTA: El componente TEvent no es más que un wrapper de un evento de Windows.