Uso de std::condition_variable para sincronización entre hilos

std::condition_variable es un mecanismo de sincronización que permite que un hilo suspenda su ejecución hasta que se cumpla una condición específica, evitando el busy waiting (bucle de espera activa) que consumiría ciclos de CPU innecesariamente. Para funcionar, requiere obligatoriamente un std::unique_lock<std::mutex>. El método wait realiza una operación crítica: libera el mutex de forma atómica y pone al hilo a dormir; si esta liberación no fuera atómica, otro hilo no podría adquirir el mutex para cambiar el estado y enviar la señal, provocando un interbloqueo. Debes usar siempre una expresión lógica (predicado) en el wait para protegerte de los spurious wakeups (despertares espurios), que son situaciones donde el sistema operativo despierta al hilo sin que haya habido una notificación real. Si tu objetivo es simplemente esperar a que un hilo te devuelva un valor, no uses una condición; para eso es mejor usar std::promise y std::future [C++11].

#include <iostream>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>

// Buffer seguro para el patrón Productor-Consumidor
template <typename T>
class BufferSeguro {
private:
    std::queue<T> cola_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool finalizado_ = false;

public:
    // El productor llama a esto para añadir elementos
    void empujar(T valor) {
        {
            // Usamos lock_guard porque aquí solo necesitamos bloqueo de scope
            std::lock_guard<std::mutex> lock(mtx_);
            cola_.push(std::move(valor));
        } 
        // Notificamos a un hilo que esté esperando en la cola
        cv_.notify_one();
    }

    // El consumidor llama a esto para extraer elementos
    bool extraer(T& salida) {
        std::unique_lock<std::mutex> lock(mtx_);
        
        // El predicado (lambda) es vital contra los spurious wakeups.
        // El hilo se duerme si la cola está vacía Y no se ha finalizado.
        cv_.wait(lock, [this] { 
            return !cola_.empty() || finalizado_; 
        });

        if (cola_.empty() && finalizado_) {
            return false; // Ya no hay nada que procesar
        }

        salida = std::move(cola_.front());
        cola_.pop();
        return true;
    }

    void terminar() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            finalizado_ = true;
        }
        // Despertamos a todos los consumidores para que vean el estado 'finalizado'
        cv_.notify_all();
    }
};

int main() {
    BufferSeguro<int> buffer;

    // Thread Productor
    std::thread productor([&]() {
        for (int i = 1; i <= 5; ++i) {
            std::this_thread::sleep_for(std::chrono::milliseconds(150));
            std::cout << "[Productor] Generando: " << i << "\n";
            buffer.empujar(i);
        }
        buffer.terminar();
    });

    // Thread Consumidor
    std::thread consumidor([&]() {
        int dato;
        while (buffer.extraer(dato)) {
            std::cout << "[Consumidor] Procesado: " << dato << "\n";
        }
        std::cout << "[Consumidor] Bucle terminado.\n";
    });

    productor.join();
    consumidor.join();

    return 0;
}

Para que el código anterior funcione, fíjate en cómo interactúan los componentes de BufferSeguro. La clase utiliza un std::mutex mtx_ para proteger tanto la std::queue como la variable finalizado_.

En el método empujar, utilizamos std::lock_guard porque solo necesitamos asegurar la integridad de la cola durante la inserción. Una vez que el lock sale del scope, llamamos a cv_.notify_one(). Es importante notar que la notificación se hace fuera del bloqueo para permitir que, si el consumidor despierta, el mutex ya esté disponible para él.

En extraer, la situación es distinta. Usamos std::unique_lock porque cv_.wait necesita la capacidad de desbloquear y volver a bloquear el mutex de forma interna. Cuando el hilo llega a cv_.wait, el compilador evalúa el predicado (el lambda). Si el predicado devuelve false (cola vacía y no finalizado), el hilo libera el mutex y se bloquea. Cuando un hilo llama a notify_one, el hilo consumidor se despierta, el wait re-adquiere el mutex y vuelve a evaluar el predicado. Si el predicado es true, el wait termina y el hilo continúa con el control del mutex.

Finalmente, en terminar, usamos cv_.notify_all() en lugar de notify_one(). Esto es crucial para asegurar que todos los hilos que estén durmiendo en el wait se despierten, evalúen la condición de finalizado_ y salgan del bucle de consumo de forma limpia.

El error frecuente
Un error clásico es usar un if en lugar de un while (o el predicado de std::condition_variable::wait) para verificar la condición. Si escribes esto:

// ERROR: Código propenso a crashes
std::unique_lock<std::mutex> lock(mtx_);
if (cola_.empty()) {
    cv.wait(lock); 
}
int valor = cola_.front(); // <--- Undefined Behavior si hubo un spurious wakeup

Si ocurre un spurious wakeup, el hilo despertará con el mutex bloqueado pero la cola_ seguirá vacía. El if no detectará que la condición sigue sin cumplirse y el programa intentará hacer front() en una cola vacía, provocando un fallo de segmentación o comportamiento indefinido. Este error es extremadamente difícil de depurar porque es intermitente y depende de la carga del sistema. AddressSanitizer (ASan) lo detectará si ocurre una violación de memoria en la std::queue.

98

Dejar un comentario

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

Scroll al inicio