Introducción al uso de hilos en Qt

Introducción al uso de hilos en Qt

Debido a la existencia del bucle de mensajes, no se pueden ejecutar tareas de larga duración en los slots. Si lo hiciéramos, la ejecución tardaría en volver al bucle de mensajes, retrasando el momento en el que la aplicación puede procesar nuevos eventos de los usuarios.

Por eso lo habitual es que desde los slots se deleguen esas tareas a hilos de trabajo —o worker thread— de tal manera que se ejecuten mientras el hilo principal sigue procesando los eventos que lleguen a la aplicación.

Gestionar hilos con Qt

Para usar hilos en Qt se utiliza la clase QThread, donde cada instancia de dicha clase representa a un hilo de la aplicación.

Crear un hilo es tan sencillo como heredar la clase QThread y reimplementar el método run() insertando el código que queremos que ejecute el hilo. En este sentido, el método QThread::run() es para el hilo, lo que la función main() es para la aplicación.

class MyThread : public QThread
{
    Q_OBJECT

protected:
    void run();
};

void MyThread::run()
{
    // Aquí el código a ejecutar en el hilo...
}

Una vez instanciada la clase, iniciar el nuevo hilo es tan sencillo como invocar el método QThread::start().

MyThread thread;
thread.start()

El hilo terminará cuando la ejecución retorne de su método MyThread::run() o si desde el código del hilo se invocan los métodos QThread::exit() o QThread::quit().

Problema del buffer finito

Generalmente, los hilos no se crean directamente en los slots en los que son necesarios, sino en la función main(), en el constructor de la clase de la ventana que los va a utilizar o en otros sitios similares. Eso es así por una cuestión de eficiencia, ya que crear y destruir hilos según cuando son necesarios tiene cierto coste.

La única cuestión es que entonces un slot debe poder entregar la tarea al hilo correspondiente que ha sido creado previamente. Como todos los hilos comparten la memoria del proceso, esto no debe ser un problema, pero realmente entraña ciertas dificultades relacionadas con la concurrencia.

Para ilustrarlo supongamos que hemos abierto un archivo de vídeo para procesarlo y que un slot de la clase de la ventana es invocado cada vez que se dispone de un nuevo frame. De hecho, un ejemplo de cómo usar de esta manera QMovie se trata en el artículo Como usar QMovie en Qt.

La función del slot sería la de transferir al hilo el frame para que se haga cargo de su procesamiento. Teniendo esto en cuenta, el problema al que nos enfrentamos podría ser descrito de la siguiente manera:

  • El slot obtiene los frames, por lo que sería nuestro productor. Como se ejecuta desde el bucle de mensajes, sabemos que siempre lo hace dentro del hilo principal del proceso.

  • El hilo de trabajo encargado del procesamiento sería nuestro consumidor, ya que toma los frames entregados por el productor.

  • Ambos comparten un buffer de frames de tamaño fijo que se usa a modo de cola circular. El productor insertaría los frames en la cola mientras el consumidor los extraería.

  • No será un problema que el productor añada más frames de los que caben en la cola porque la cola será circular. Es decir, aunque se llene, se siguen añadiendo frames sobrescribiendo los más antiguos. Es preferible perder frames a hacer crecer la cola, retrasando cada vez más el procesamiento de los nuevos frames, hasta quedarnos sin memoria. Para que esto funcionen productor y consumidor tendrán que compartir las posiciones del primer y último elemento de la cola.

  • Si habrá que controlar que el consumidor no intente extraer más frames cuando ya no queden.

Para que todo esto funcione correctamente vamos a necesitar una serie de elementos de sincronización que ayuden a ambos hilos a coordinarse:

  • Un cerrojo —o mutex— de exclusión mutua QMutex que serialice la ejecución del código en ambos hilos que manipulan la cola y su contador. La idea es que mientras uno de los hilos esté manipulando la cola, el otro tenga que esperar.

  • Una condición de espera QWaitCondition para que el consumidor pueda dormir mientras la cola esté vacía. La siguiente vez que el productor inserte un frame en la cola, utilizaría la condición de espera para notificar al consumidor que puede volver a extraerlos.

Teniendo todo esto presente, a continuación desarrollamos una posible solución.

La clase FiniteBuffer

Vamos a encapsular el buffer compartido dentro de una clase propia, de tal forma que el acceso al mismo solo pueda realizarse usando los métodos seguros que implementaremos.

  • void insertFrame(const QImage& frame) Insertar la imagen frame en el buffer de frames.

  • QImage extractFrame() Extraer el frame más antiguo del buffer.

Como ya hemos comentado, los hilos deben compartir: la cola, las posiciones del primer y último elemento de la cola y una serie de objetos de sincronización:

class FiniteBuffer : public QObject
{
    Q_OBJECT

public:
    FiniteBuffer(int size);
    ~FiniteBuffer();

    // Métodos de inserción y extracción para el productor y el
    // consumidor, respectivamente.
    void insertFrame(const QImage& frame);
    QImage extractFrame();

private:
    /* La cola de frames se puede construir con un array de C:
        const int bufferSize_;    // Tamaño de la cola
        QImage[] buffer_;         // Cola de frames como array de C
       pero es más cómodo y menos propenso a errores usar QVector,
       std::vector o estructuras de datos similares de C++ o Qt.
    */
    QVector<QImage> buffer_;    // Cola de frames
    int bufferTail_;        // Posición del último frame insertado
    int bufferHead_;        // Posición del último frame extraído

    // Objetos de sincronización
    QWaitCondition bufferNotEmpty_;
    QMutex mutex_;
}

que debemos inicializar adecuadamente en el constructor de nuestra nueva clase:

FiniteBuffer::FiniteBuffer(int size)
    : buffer_(size), numUsedBufferItems_(0),
      bufferHead_(-1), bufferTail_(-1)
{}

El productor

El código en el slot de la ventana principal llamado cada vez que se dispone de un nuevo frame podría tener el siguiente aspecto:

void MyWindow::on_video_updated(const QRect& rect)
{
    finiteBuffer_->insertFrame(movie_->currentImage());
}

siendo el método FiniteBuffer::insertFrame() el siguiente:

void FiniteBuffer::insertFrame(const QImage& frame)
{
    // Bloquear el cerrojo. Es lo mismo que hacer manualmente:
    // mutex_.lock()
    QMutexLocker lock(&mutex);

    // El código del productor a partir de este punto no se
    // ejecutará si el consumidor ha bloqueado el cerrojo
    // primero.

    // Insertar el frame en la cola
    buffer_[++bufferTail_ % buffer_.size()] = frame;
    bufferNotEmpty_.wakeAll();      // Despertar al consumidor si
                                    // esperaba por más frames.

    // El cerrojo se libera automáticamente al salir de la función
    // y destruirse lock. Es lo mismo que hacer manualmente:
    // mutex_.unlock()
}

Donde la instancia lock de la clase QMutexLocker sirve para evitar que el productor y el consumidor accedan al contador compartido al mismo tiempo. Concretamente:

  • El primero crea el objeto QMutexLocker obtiene el cerrojo mutex. Si un segundo hilo llega a ese método mientras el otro tiene el cerrojo, simplemente se duerme a la espera de que el cerrojo sea liberado por el primero.

  • El salir del método se libera el cerrojo mutex. En ese momento uno de los hilos que espera obtener el cerrojo se despierta y lo obtiene, para continur con su ejecución.

Usar QMutexLocker equivalente a llamar directamente a QMutex::lock() y QMutex::unlock() para obtener y liberar el cerrojo mutex. Sin embargo, es mejor utilizar QMutexLocker siempre, porque reduce las posibilidades de cometer el error de olvidarnos de liberar mutex.

Por otro lado, las instancias de condiciones de espera QWaitCondition permiten dormir un hilo hasta que se dé una condición determinada. Como se verá más adelante, consumidor utiliza el método QWaitCondition::wait() para dormir si la cola está vacía. Antes de hacerlo, libera temporalmente el cerrojo mutex_, permitiendo que el productor se pueda ejecutar en el código que protege.

El productor utiliza el método QWaitCondition::weakAll() después de insertar un elemento con el objeto de despertar al consumidor. Obviamente, este deberá bloquear el cerrojo mutex_ antes de volver del método QWaitCondition::wait().

El consumidor

El código del hilo consumidor podría tener el siguiente aspecto:

class FrameProcessingThread : public QThread
{
    Q_OBJECT

public:
    FrameProcessingThread(FiniteBuffer* buffer,
                          QObject *parent = 0)
        : QThread(parent), buffer_(buffer)
    {}

    void run()
    {
        while(true) {
            QImage image = buffer_->extractFrame();

            //
            // Aquí va el código para procesar cada frame...
            //
            // ...
            //
        }
    }

private:
    FiniteBuffer* buffer_;
};

donde el código del método FiniteBuffer::removeFrame() es muy similar al de inserción:

QImage FiniteBuffer::extractFrame()
{
    // Bloquear el cerrojo. Es lo mismo que hacer manualmente:
    // mutex_.lock()
    QMutexLocker lock(&mutex);

    // El código del productor a partir de este punto no se
    // ejecutará si el productor ha bloqueado el cerrojo
    // primero.

    if (bufferHead_ == bufferTail_)     // ¿Cola vacía?...
        bufferNotEmpty_.wait(&mutex);   // Dormir si es así
    }
    QImage image = buffer_[++bufferHead_ % buffer_.size()];

    // El cerrojo se libera automáticamente al salir de la función
    // y destruirse lock. Es lo mismo que hacer manualmente:
    // mutex_.unlock()  
    return image;
}

El constructor de la ventana principal

Finalmente, es en constructor de ventana principal del programa MyWindow donde debe crearse el buffer FiniteBuffer y el hilo encargado del procesamiento de los frames. Es decir, nuestro consumidor.

MyWindow::MyWindow(QWidget *parent)
{
    // ...

    finiteBuffer_ = new FiniteBuffer(20);
    FrameProcessingThread frameProcessingThread(finiteBuffer_);
    frameProcessingThread.start();

    // ...
}

Referencias