C++/CLI VIII: Usando hilos… con hilos

Si ayer hablaba sobre cómo usar hilos sin hilos, ahora vamos a hablar de hacer lo mismo pero utilizando el componente Thread, que es como se deben manejar, o mejor dicho, una de las formas adecuadas de trastearlos.

Ayer comentábamos que lo habitual a la hora de crear un hilo para que realice una tarea es usar algo como esto:

   1: Thread ^hilo=gcnew Thread(gcnew ThreadStart(this,&Clase::Método));
   2: hilo->Start();

En donde &Clase::Método es la dirección del método que va a realizar el trabajo. Pero vamos a completar el ejemplo. Supongamos que tenemos que realizar una tarea X que se pueda realizar de forma asíncrona sin necesidad de que el usuario intervenga. La solución pasa por realizar algo como lo que explicamos ayer o como lo que vamos a contar ahora. Pero primero el código fuente:

   1: #include "stdafx.h"
   2:  
   3: using namespace System;
   4: using namespace System::Threading;
   5:  
   6: ref class Worker
   7: {
   8:     Thread ^m_thread;
   9: public:
  10:     Worker(void)
  11:     {
  12:     }
  13:     void DoWork(void)
  14:     {
  15:         Console::WriteLine("Empiezo a currar");
  16:         Thread::Sleep(10000);
  17:         Console::WriteLine("Termino de currar");
  18:     }
  19:     void StartWorking(void)
  20:     {
  21:         m_thread=gcnew Thread(gcnew ThreadStart(this,&Worker::DoWork));
  22:         m_thread->Start();
  23:     }
  24:     void WaitFinished(void)
  25:     {
  26:         while(m_thread->IsAlive)
  27:         {
  28:             Console::WriteLine("¿Terminaste?");
  29:             Thread::Sleep(1000);
  30:         }
  31:     }
  32: };
  33:  
  34: int main(array<System::String ^> ^args)
  35: {
  36:     Worker ^w=gcnew Worker();
  37:     w->StartWorking();
  38:     w->WaitFinished();
  39:     Console::WriteLine("Ya terminó");
  40:     Console::ReadKey(true);
  41:     return 0;
  42: }

Primero hemos definido una clase que llamamos Worker y que es la clase que va a hacer la tarea. Pero esto es un ejemplo, y en general todo lo que aparece en la clase podría aparecer dentro de una ficha normal y corriente. Tenemos un método llamado DoWork() que se encarga de hacer una tarea más o menos pesada; en nuestro caso el hilo se duerme durante 10 segundos. También disponemos de un método para iniciar el trabajo que hemos llamado, en un asombroso alarde de imaginación, StartWorking(). Asimismo hemos creado otro más, encargado de esperar mediante un bucle hasta que se termine de realizar la faena.

En main() instanciamos la clase, hacemos una llamada a StartWorking() para que se inicie el trabajo y finalmente esperamos a que éste termine.

Como el lector habrá podido darse cuenta, la utilidad de hacer esto es poca, ya que seguimos teniendo bloqueado el hilo principal del programa, por lo que podríamos transformar la función de espera por un temporizador que comprobara si el hilo ha acabado o no periódicamente, o instalar un delegado que nos notifique cuando el hilo acaba haciendo una llamada a dicho delegado al finalizar el mismo, pero entonces nos estamos acercando al modelo de ejecución asíncrona mediante delegados ya explicados.

Ahora imaginemos algo más sencillo: necesitamos imprimir un documento largo y, aunque el .NET lo suele hacer rápido con el componente PrintDocument, siempre congelará nuestra interfaz, por lo que lo usual sería utilizar la primera parte de lo explicado para lanzar un hilo que imprima y dejarlo que muera él solo. Incluso podríamos lanzar varios hilos simultáneamente y olvidarnos de ellos, aunque para eso tenemos los Thread Pools y los BackgrounWorkers, que quizás explique en algún momento más adelante.

Otro acercamiento

Trabajar con hilos puede ser o muy fácil o muy difícil, depende de lo que queramos… y cómo nos lo planteemos. Ahora supongamos que estamos realizando una tarea de larga duración en un hilo y que el usuario cierre el programa. ¿Qué hacemos con el hilo? ¿Lo matamos a lo bruto? ¿Esperamos a que termine? ¿Y si no podemos matarlo, es decir, el hilo debe salir graciosamente cerrando todas sus tareas? ¿Cómo se lo comunicamos?

El caso típico en mi trabajo consiste en que estoy hablando con un periférico (o manteniendo el estado del mismo) mediante un hilo y el usuario decide cerrar el programa. Pero hay periféricos a los que no se puede dejar de atender así como así, y menos aún si están realizando una tarea crítica, como procesando cualquier transacción. Dicha transacción ha de finalizar, y luego hemos de decirle al periférico que se deshabilite. También debemos implicar al usuario, informándole de que el hilo está terminando.

Veamos la aproximación más sencilla. Primero el código.

   1: #include "stdafx.h"
   2:  
   3: using namespace System;
   4: using namespace System::Threading;
   5:  
   6: ref class Tareas
   7: {
   8: private:
   9:     volatile bool m_bTerminateThread;
  10:     volatile bool m_bThreadIsFinished;
  11:  
  12:     Thread ^m_thread;
  13:     void ThreadHazTareas(void)
  14:     {
  15:         m_bThreadIsFinished=false;
  16:         int tarea=0;
  17:         for(;;)
  18:         {
  19:             Console::WriteLine("Tarea {0}.1",tarea);
  20:             Thread::Sleep(1000);
  21:             Console::WriteLine("Tarea {0}.2",tarea);
  22:             Thread::Sleep(1000);
  23:             Console::WriteLine("Tarea {0}.3",tarea);
  24:             Thread::Sleep(1000);
  25:             Console::WriteLine("Tarea {0}.4",tarea);
  26:             Thread::Sleep(1000);
  27:             tarea++;
  28:             if(m_bTerminateThread)
  29:             {
  30:                 m_bThreadIsFinished=true;            
  31:                 return;
  32:             }
  33:         }
  34:     }
  35: public:
  36:     property bool HaTerminado
  37:     {
  38:         bool get(void){return m_bThreadIsFinished;}
  39:     }
  40:     void IniciaTareas(void)
  41:     {
  42:         m_bTerminateThread=false;
  43:         m_thread=gcnew Thread(gcnew ThreadStart(this,&Tareas::ThreadHazTareas));
  44:         m_thread->Start();
  45:     }
  46:     void TerminaTareas(void)
  47:     {
  48:         m_bTerminateThread=true;
  49:     }
  50: };
  51: int main(array<System::String ^> ^args)
  52: {
  53:     Tareas ^t=gcnew Tareas();
  54:     t->IniciaTareas();
  55:     Thread::Sleep(5000);
  56:     Console::WriteLine("Orden: finalizar");
  57:     t->TerminaTareas();
  58:     while(!t->HaTerminado)
  59:         ;
  60:     Console::WriteLine("Finalizó");
  61:       Console::ReadKey(true);
  62:     return 0;
  63: }

Aparentemente es algo más complicado que el anterior, pero sólo a simple vista. Luego pondremos un resumen del mismo.

Hemos creado una clase llamada Tareas que engloba varios métodos y propiedades. En primer lugar tenemos el método ThreadHazTareas(), que es el método que hará de hilo. En él tenemos un bucle infinito que realiza una tarea en cuatro etapas que podría significar una máquina de estados de cuatro fases que deban hacerse en secuencia y no ser interrumpidas. Observamos que la primera sentencia dentro del hilo es

   1: m_bThreadIsFinished=false;

Estamos afirmando que el hilo está es marcha. Tras la realización de las tareas, tenemos otro bloque interesante:

   1: if(m_bTerminateThread)
   2: {
   3:     m_bThreadIsFinished=true;            
   4:     return;
   5: }

Aquí estamos indicando que si externamente se nos ha indicado que el hilo debe terminar, lo terminamos aquí y sólo aquí. Estamos evitando que el proceso que creó el hilo lo mate en una situación inadecuada.

Lo siguiente es trivial. Una propiedad que nos devuelve cierto si el hilo ha terminado (es decir, se ha ejecutado el código anterior), otro método para arrancar el hilo y uno más para decirle que debe terminar.

En main() lo habitual: la creación del objeto, la iniciación de las tareas, un rato de espera y el envío de la orden de finalización, con la espera hasta que termine. En un programa más serio, dicho código podría ir en un temporizador, o incluso como ya hemos indicado antes, con la llamada de un delegado.

También podríamos haber utilizado m_thread->IsAlive pero al autor le gusta esta forma, ya que tiene más control.

Muy importante

Un detalle que hemos dejado para el final: la palabra volatile delante de los dos valores lógicos. ¿Qué significa? ¿Podemos obviarla? No, de ninguna manera. Estamos instruyendo al compilador para que no presuponga que esas variables no van a ser modificadas externamente, es decir, le estamos diciendo que compruebe la variable siempre y que tanto a la hora de leerla o de escribirla, use la propia variable y no un valor en algún tipo de caché.

Una variable de tipo bool es lo que se llama una variable atómica, es decir, está garantizado que la variable se actualiza mediante una sola instrucción máquina y que no hay pasos intermedios, y si pudiera haber alguno, lo hemos eliminado al calificarla como volátil.

Si no fuera así, podría ocurrir que el hilo principal accediera a la misma y, a medio hacer alguna operación sobre ella, el sistema conmutara al hilo y que éste también realizara alguna otra operación. El estado final de la misma quedaría completamente indeterminado, y es un problema habitual con los programas multihilo. Para eso están los bloqueos y las secciones críticas, pero nosotros nos las hemos arreglado sin necesidad de ellos y sin complicarnos mucho la vida, dado que es físicamente imposible que se acceda de forma simultánea a la variable desde dos hilos diferentes ya que si el micro está leyéndola o escribiéndola, la operación se realiza en una sola instrucción máquina.

Se podría poner una última pega a esta aproximación simplificada de una sección crítica, y es que justo una vez la variable haya sido leída e introducida en un registro del micro, el sistema operativo conmutara a otro hilo y éste variara su valor en memoria. Pero no existe ningún problema, ya que cuando retornáramos, tendríamos el valor anterior y tomaríamos el bueno en el siguiente ciclo ya que se ha especificado como volátil. Como se trata de una variable bi-estado, y sólo la usamos una vez, no existe problema de ningún tipo. Ojo, pero siempre, siempre, calificándola como volátil, ya que entonces el compilador podría cachearla dentro de un registro y no atender a cambios externos.

Por último, una captura de lo que le ha ocurrido al autor en su máquina:image

Finalmente comentar que aunque los ejemplos estén escritos en C++/CLI, los conceptos son perfectamente válidos para cualquier lenguaje en el que una variable de tipo bool sea una variable atómica, como C# ó C++.

2 comentarios sobre “C++/CLI VIII: Usando hilos… con hilos”

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *