Zum Inhalt springen

Bedingungsvariablen & Producer-Consumer

Nutze Bedingungsvariablen, um Threads effizient zu koordinieren. Baue eine thread-sichere Producer-Consumer-Queue, gehe mit Spurious Wakeups um und vermeide das Problem der verlorenen Benachrichtigung.

Thread-Koordination mit Bedingungsvariablen

Eine Bedingungsvariable lässt einen Thread warten, bis eine Bedingung wahr wird, ohne dabei in einer Warteschleife zu rotieren. Statt in while (!ready) { /* burn CPU */ } zu kreisen, schläft der Thread und wird von einem anderen Thread geweckt, der notify_one() oder notify_all() aufruft.

std::condition_variable funktioniert ausschließlich mit std::unique_lock. Das Muster ist immer dasselbe:

1. Sperre den Mutex.
2. Prüfe die Bedingung — ist sie falsch, rufe wait() auf, das den Mutex atomar freigibt und den Thread schlafen legt.
3. Sobald benachrichtigt wurde (und die Bedingung wahr ist), wird der Mutex wieder erworben und die Ausführung läuft weiter.

Der benachrichtigende Thread ändert den gemeinsamen Zustand unter demselben Mutex und ruft dann notify_one() oder notify_all() auf.

Grundlegende Verwendung einer Bedingungsvariablen

Hier ist das grundlegende Muster: Ein Thread wartet auf Daten, ein anderer liefert sie.

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

Eine Bedingungsvariable kann einen Thread auch dann wecken, wenn gar keine Benachrichtigung gesendet wurde — das nennt man Spurious Wakeup (spontanes Aufwachen). Der C++-Standard erlaubt das aus Performance-Gründen (und auf realen Systemen kommt es tatsächlich vor). Du musst immer die Prädikat-Variante von wait() verwenden oder den Aufruf in eine while-Schleife packen:

// RICHTIG — die Prädikat-Variante behandelt Spurious Wakeups
cv.wait(lock, []{ return ready; });

// GLEICHWERTIGE manuelle Schleife
while (!ready) {
    cv.wait(lock);
}

// FALSCH — wacht zufällig auf und macht ohne Daten weiter
cv.wait(lock);  // keine Prüfung der Bedingung!

Rufe cv.wait(lock) niemals ohne Prädikat oder umgebende while-Schleife auf.

Thread-sicherer beschränkter Puffer (Producer-Consumer)

Das Producer-Consumer-Muster (Erzeuger-Verbraucher) ist eines der häufigsten Nebenläufigkeitsmuster. Producer legen Elemente in einen gemeinsamen Puffer, Consumer entnehmen sie. Ein beschränkter Puffer blockiert Producer, wenn er voll ist, und Consumer, wenn er leer ist.

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

Das Problem der verlorenen Benachrichtigung

Eine verlorene Benachrichtigung (Lost Wakeup) tritt auf, wenn notify_one() aufgerufen wird, bevor der Consumer wait() aufruft. Da kein Thread wartet, geht die Benachrichtigung verloren — der Consumer schläft anschließend für immer.

Das Standardmuster verhindert das, indem es das Prädikat vor dem Schlafenlegen prüft. Hat der Producer die Bedingung bereits auf wahr gesetzt, liefert das Prädikat wahr und wait() kehrt sofort zurück, ohne zu schlafen. Genau deshalb ist die Prädikat-Variante cv.wait(lock, pred) unverzichtbar — sie prüft die Bedingung vor und nach jedem Aufwachen:

1. Sperre den Mutex.
2. Prüfe das Prädikat — ist es bereits wahr, entfällt das Warten.
3. Andernfalls: atomar entsperren und schlafen legen.
4. Beim Aufwachen: erneut sperren und das Prädikat erneut prüfen.

Solange sowohl Producer als auch Consumer das gemeinsame Flag mit demselben Mutex schützen, kann keine Benachrichtigung verloren gehen.

notify_one vs. notify_all

notify_one() weckt einen wartenden Thread, notify_all() weckt alle. Verwende notify_all(), wenn mehrere Threads ihre Bedingungen neu prüfen müssen (z. B. bei Shutdown-Signalen), und notify_one(), wenn genau ein Thread weiterlaufen soll (z. B. ein einzelner Consumer, der eine Aufgabe übernimmt).

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

Verwende immer die Prädikat-Überladung von wait(), um dich gegen Spurious Wakeups und verlorene Benachrichtigungen abzusichern. Gib den Mutex nach Möglichkeit frei, bevor du notify_one()/notify_all() aufrufst — so blockiert der geweckte Thread nicht sofort wieder am Mutex. Bevorzuge notify_one(), wenn nur ein Thread aufwachen muss, und notify_all() für Broadcast-Szenarien wie einen Shutdown. Warte niemals mit einem lock_guard auf einer Bedingungsvariablen — dafür brauchst du unique_lock.

Wichtigste Erkenntnisse
  • std::condition_variable lässt Threads schlafen, bis eine Bedingung wahr ist, und vermeidet so teures aktives Warten
  • Verwende immer die Prädikat-Variante von wait(), um Spurious Wakeups und verlorene Benachrichtigungen abzufangen
  • Das Producer-Consumer-Muster nutzt zwei Bedingungsvariablen: "nicht voll" und "nicht leer"
  • notify_one() weckt einen Wartenden, notify_all() weckt alle — wähle die passende Variante für dein Szenario
  • Wartender und Benachrichtigender müssen denselben Mutex verwenden, um die gemeinsame Bedingung zu schützen

Quiz — Teste dein Wissen

(20 XP)

1. Warum musst du bei `condition_variable::wait()` immer ein Prädikat verwenden?

2. Was sollte der Producer in einer beschränkten Producer-Consumer-Queue tun, wenn die Queue voll ist?

3. Welchen Lock-Typ benötigt `std::condition_variable::wait()`?