Vai al contenuto

Variabili di condizione e produttore-consumatore

Usa le variabili di condizione per coordinare i thread in modo efficiente. Costruisci una coda produttore-consumatore thread-safe, gestisci i risvegli spuri ed evita il problema del risveglio perso.

Coordinare i thread con le variabili di condizione

Una variabile di condizione permette a un thread di attendere finché una certa condizione non diventa vera, senza cicli di attesa attiva. Invece di girare a vuoto con while (!ready) { /* burn CPU */ }, il thread si mette in pausa e viene risvegliato da un altro thread che chiama notify_one() o notify_all().

std::condition_variable funziona solo con std::unique_lock. Il pattern è sempre questo:

1. Acquisisci il lock sul mutex.
2. Controlla la condizione — se è falsa, chiama wait(), che rilascia atomicamente il mutex e sospende il thread.
3. Quando arriva la notifica (e la condizione è vera), il mutex viene riacquisito e l'esecuzione prosegue.

Il thread che notifica modifica lo stato condiviso sotto lo stesso mutex, poi chiama notify_one() o notify_all().

Uso di base di una variabile di condizione

Ecco il pattern fondamentale: un thread attende i dati, un altro glieli fornisce.

condition_variable_basic.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <string>

std::mutex mtx;
std::condition_variable cv;
std::string data;
bool ready = false;

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    // Wait until ready is true (handles spurious wakeups)
    cv.wait(lock, []{ return ready; });
    std::cout << "Consumer received: " << data << "\n";
}

void producer() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        data = "Hello from producer";
        ready = true;
    }  // unlock before notify to avoid waking thread into contention
    cv.notify_one();
}

int main() {
    std::thread t1(consumer);
    std::thread t2(producer);
    t1.join();
    t2.join();
    return 0;
}
Insidia

Una variabile di condizione può risvegliare un thread anche quando non è stata inviata alcuna notifica — questo fenomeno si chiama risveglio spurio. Lo standard C++ lo consente (ed è comune sui sistemi reali) per ragioni di prestazioni. Devi sempre usare la forma di wait() con predicato oppure racchiuderla in un ciclo while:

// CORRETTO — la forma con predicato gestisce i risvegli spuri
cv.wait(lock, []{ return ready; });

// Ciclo manuale EQUIVALENTE
while (!ready) {
    cv.wait(lock);
}

// SBAGLIATO — si risveglia a caso e prosegue senza dati
cv.wait(lock);  // nessun controllo della condizione!

Non chiamare mai cv.wait(lock) senza un predicato o senza un ciclo while che lo racchiuda.

Buffer limitato thread-safe (produttore-consumatore)

Il pattern produttore-consumatore è uno dei pattern di concorrenza più diffusi. I produttori aggiungono elementi a un buffer condiviso; i consumatori li rimuovono. Un buffer limitato blocca i produttori quando è pieno e i consumatori quando è vuoto.

bounded_queue.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <vector>

template <typename T>
class BoundedQueue {
    std::queue<T> queue_;
    std::mutex mtx_;
    std::condition_variable not_full_;
    std::condition_variable not_empty_;
    size_t capacity_;
public:
    explicit BoundedQueue(size_t cap) : capacity_(cap) {}

    void push(T item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_full_.wait(lock, [this]{ return queue_.size() < capacity_; });
        queue_.push(std::move(item));
        not_empty_.notify_one();
    }

    T pop() {
        std::unique_lock<std::mutex> lock(mtx_);
        not_empty_.wait(lock, [this]{ return !queue_.empty(); });
        T item = std::move(queue_.front());
        queue_.pop();
        not_full_.notify_one();
        return item;
    }
};

int main() {
    BoundedQueue<int> bq(5);  // capacity 5

    // Producer
    std::thread producer([&bq]() {
        for (int i = 0; i < 20; ++i) {
            bq.push(i);
            std::cout << "Produced: " << i << "\n";
        }
    });

    // Consumer
    std::thread consumer([&bq]() {
        for (int i = 0; i < 20; ++i) {
            int val = bq.pop();
            std::cout << "Consumed: " << val << "\n";
        }
    });

    producer.join();
    consumer.join();
    return 0;
}

Il problema del risveglio perso

Un risveglio perso si verifica quando notify_one() viene chiamata prima che il consumatore chiami wait(). Dato che nessun thread è in attesa, la notifica va persa — e il consumatore dorme per sempre.

Il pattern standard previene questa situazione controllando il predicato prima di mettersi in attesa. Se il produttore ha già portato la condizione a vera, il predicato restituisce true e wait() ritorna immediatamente senza sospendere il thread. Ecco perché la forma con predicato cv.wait(lock, pred) è essenziale — controlla la condizione prima e dopo ogni risveglio:

1. Acquisisci il lock sul mutex.
2. Controlla il predicato — se è già vero, salta l'attesa.
3. Altrimenti, rilascia atomicamente il lock e mettiti in attesa.
4. Al risveglio, riacquisisci il lock e ricontrolla il predicato.

Finché sia il produttore sia il consumatore proteggono il flag condiviso con lo stesso mutex, nessun risveglio può andare perso.

notify_one vs notify_all

notify_one() risveglia un solo thread in attesa; notify_all() li risveglia tutti. Usa notify_all() quando più thread potrebbero dover ricontrollare le proprie condizioni (per esempio i segnali di arresto) e notify_one() quando deve proseguire esattamente un thread (per esempio un singolo consumatore che preleva un task).

notify_all_shutdown.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <vector>
#include <atomic>

std::mutex mtx;
std::condition_variable cv;
bool shutdown = false;

void worker(int id) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return shutdown; });
    std::cout << "Worker " << id << " shutting down\n";
}

int main() {
    std::vector<std::thread> workers;
    for (int i = 0; i < 5; ++i) {
        workers.emplace_back(worker, i);
    }

    std::this_thread::sleep_for(std::chrono::seconds(1));

    {
        std::lock_guard<std::mutex> lock(mtx);
        shutdown = true;
    }
    cv.notify_all();  // wake ALL workers for shutdown

    for (auto& w : workers) w.join();
    return 0;
}
Buona pratica

Usa sempre l'overload di wait() con predicato per proteggerti dai risvegli spuri e da quelli persi. Quando puoi, rilascia il mutex prima di chiamare notify_one()/notify_all() — così eviti che il thread appena risvegliato si blocchi subito sul mutex. Preferisci notify_one() quando deve risvegliarsi un solo thread e notify_all() per gli scenari di broadcast come l'arresto. Non attendere mai su una variabile di condizione con un lock_guard — ti serve unique_lock.

Concetti chiave
  • std::condition_variable permette ai thread di dormire finché una condizione non è vera, evitando la costosa attesa attiva
  • Usa sempre la forma di wait() con predicato per gestire sia i risvegli spuri sia quelli persi
  • Il pattern produttore-consumatore usa due variabili di condizione: "non pieno" e "non vuoto"
  • notify_one() risveglia un solo thread in attesa; notify_all() li risveglia tutti — scegli quella giusta per il tuo scenario
  • Sia chi attende sia chi notifica devono usare lo stesso mutex per proteggere la condizione condivisa

Quiz — Metti alla prova le tue conoscenze

(20 XP)

1. Perché devi usare sempre un predicato con `condition_variable::wait()`?

2. In una coda produttore-consumatore limitata, cosa dovrebbe fare il produttore quando la coda è piena?

3. Quale tipo di lock richiede `std::condition_variable::wait()`?