Aller au contenu

Variables de condition et producteur-consommateur

Utilisez les variables de condition pour coordonner efficacement les threads. Construisez une file producteur-consommateur thread-safe, gérez les réveils intempestifs et évitez le problème du réveil perdu.

Coordonner des threads avec les variables de condition

Une variable de condition permet à un thread d'attendre qu'une condition devienne vraie, sans boucle d'attente active. Au lieu de tourner en boucle avec while (!ready) { /* burn CPU */ }, le thread s'endort et est réveillé par un autre thread qui appelle notify_one() ou notify_all().

std::condition_variable fonctionne uniquement avec std::unique_lock. Le schéma est toujours le même :

1. Verrouiller le mutex.
2. Vérifier la condition — si elle est fausse, appeler wait(), qui libère atomiquement le mutex et suspend le thread.
3. Une fois notifié (et la condition vraie), le mutex est réacquis et l'exécution se poursuit.

Le thread notificateur modifie l'état partagé sous le même mutex, puis appelle notify_one() ou notify_all().

Utilisation de base d'une variable de condition

Voici le schéma fondamental : un thread attend des données, un autre les fournit.

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;
}
Piège

Une variable de condition peut réveiller un thread même si aucune notification n'a été envoyée — c'est ce qu'on appelle un réveil intempestif (spurious wakeup). La norme C++ l'autorise (et c'est fréquent sur les systèmes réels) pour des raisons de performance. Vous devez toujours utiliser la forme de wait() avec prédicat, ou l'entourer d'une boucle while :

// CORRECT — la forme avec prédicat gère les réveils intempestifs
cv.wait(lock, []{ return ready; });

// Boucle manuelle ÉQUIVALENTE
while (!ready) {
    cv.wait(lock);
}

// FAUX — se réveille aléatoirement, continue sans données
cv.wait(lock);  // aucune vérification de condition !

N'appelez jamais cv.wait(lock) sans prédicat ni boucle while autour.

Tampon borné thread-safe (producteur-consommateur)

Le modèle producteur-consommateur est l'un des schémas de concurrence les plus courants. Les producteurs ajoutent des éléments dans un tampon partagé ; les consommateurs les en retirent. Un tampon borné bloque les producteurs lorsqu'il est plein et les consommateurs lorsqu'il est vide.

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;
}

Le problème du réveil perdu

Un réveil perdu (lost wakeup) se produit lorsque notify_one() est appelé avant que le consommateur n'appelle wait(). Comme aucun thread n'attend, la notification est perdue — et le consommateur s'endort alors pour toujours.

Le schéma standard l'évite en vérifiant le prédicat avant de s'endormir. Si le producteur a déjà rendu la condition vraie, le prédicat renvoie true et wait() retourne immédiatement sans s'endormir. C'est pourquoi la forme avec prédicat cv.wait(lock, pred) est indispensable — elle vérifie la condition avant et après chaque réveil :

1. Verrouiller le mutex.
2. Vérifier le prédicat — s'il est déjà vrai, ne pas attendre.
3. Sinon, déverrouiller et s'endormir de manière atomique.
4. Au réveil, reverrouiller et revérifier le prédicat.

Tant que le producteur et le consommateur protègent le drapeau partagé avec le même mutex, aucun réveil ne peut être perdu.

notify_one vs notify_all

notify_one() réveille un thread en attente ; notify_all() les réveille tous. Utilisez notify_all() lorsque plusieurs threads peuvent avoir besoin de revérifier leur condition (par exemple, un signal d'arrêt), et notify_one() lorsqu'un seul thread doit continuer (par exemple, un consommateur unique qui récupère une tâche).

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;
}
Bonne pratique

Utilisez toujours la surcharge de wait() avec prédicat pour vous prémunir contre les réveils intempestifs et les réveils perdus. Lorsque c'est possible, déverrouillez le mutex avant d'appeler notify_one()/notify_all() — cela évite que le thread réveillé se bloque aussitôt sur le mutex. Préférez notify_one() lorsqu'un seul thread doit se réveiller, et notify_all() pour les scénarios de diffusion comme l'arrêt. N'attendez jamais sur une variable de condition avec un lock_guard — il vous faut un unique_lock.

Points clés
  • std::condition_variable permet aux threads de dormir jusqu'à ce qu'une condition soit vraie, en évitant une attente active coûteuse
  • Utilisez toujours la forme de wait() avec prédicat pour gérer à la fois les réveils intempestifs et les réveils perdus
  • Le modèle producteur-consommateur utilise deux variables de condition : « pas plein » et « pas vide »
  • notify_one() réveille un thread en attente ; notify_all() les réveille tous — choisissez celui qui convient à votre scénario
  • Le thread qui attend et celui qui notifie doivent utiliser le même mutex pour protéger la condition partagée

Quiz — Testez vos connaissances

(20 XP)

1. Pourquoi faut-il toujours utiliser un prédicat avec `condition_variable::wait()` ?

2. Dans une file producteur-consommateur bornée, que doit faire le producteur lorsque la file est pleine ?

3. Quel type de verrou `std::condition_variable::wait()` exige-t-il ?