1. Warum braucht man std::condition_variable, wenn es schon ein mutex gibt
Wenn man mutex aus der Sicht eines Einsteigers betrachtet, scheint es: „Ich kann die Aufgabenwarteschlange schützen, also ist alles gut“. Und tatsächlich schützt ein mutex Daten zuverlässig vor Race-Conditions. Aber es gibt ein Problem: er kann nicht „schön warten“. Hat der Consumer nichts zu tun, dreht er entweder in einer Schleife (busy-wait) oder schläft periodisch mit sleep_for und „guckt nach“, ob Arbeit angekommen ist. Beide Ansätze sind ein Kompromiss zwischen Ressourcenverschwendung und Verzögerung.
std::condition_variable ist wie die Türklingel: man steht nicht auf der Matte und prüft alle 10 Millisekunden, ob der Kurier da ist. Man drückt den Knopf „warten“ und… wartet wirklich. Wenn jemand Pizza (eine Aufgabe) bringt, klingelt es und man macht weiter.
Drei Teilnehmer des Protokolls: Zustand, mutex und condition_variable
Jetzt kommt ein wichtiger Gedanke, den man in der Nebenläufigkeit wie ein Mantra wiederholen sollte: wir warten nicht auf ein „Signal“, wir warten auf einen Zustand. Ein Zustand ist etwas, das man „gerade jetzt“ prüfen kann: das Flag ready, ein Zähler tokens, eine Queue queue (leer/nicht leer). Und die Benachrichtigung (notify_*) ist nur ein höflicher Hinweis: „prüfe nochmal“.
Das Zusammenspiel sieht so aus: wir haben einen geschützten Zustand (z. B. die Aufgabenwarteschlange), den wir nur unter demselben mutex lesen und verändern. Daneben gibt es eine condition_variable, die einem Thread erlaubt, „einzuschlafen“, bis sich der Zustand eventuell in einen passenden Zustand ändert.
Ein kleines Schema des Protokolls:
flowchart TD
A[Consumer hat mutex erfasst] --> B{Ist der Zustand bereit?}
B -- nein --> C["cv.wait(lock, predicate) mutex wird temporär freigegeben, Thread schläft"]
C --> D[Aufwachen, mutex wird wieder erlangt]
D --> B
B -- ja --> E[Daten entnehmen und kritischen Abschnitt verlassen]
Genau dieser „Prüfzyklus“ ist in wait(lock, predicate) eingebaut.
2. Warum wait mit std::unique_lock funktioniert und nicht mit std::lock_guard
Auf den ersten Blick kann das irritieren: „Warum nicht wie gewohnt lock_guard?“ Weil wait einen Trick macht, den lock_guard prinzipiell nicht kann: er gibt den Mutex temporär frei und erwirbt ihn anschließend wieder. Der Thread soll schlafen, dabei aber den mutex nicht halten, sonst können andere Threads den Zustand nicht ändern und ihn wecken.
Schauen Sie sich das Konzept von unique_lock an: er hält nicht nur „erworben bis zum Ende des Scopes“, sondern kann auch unlock() und lock().
#include <mutex>
std::mutex m;
void unique_lock_demo() {
std::unique_lock<std::mutex> lock(m);
lock.unlock(); // Mutex freigegeben
lock.lock(); // erneut erworben
}
Und jetzt der Vergleich in der Tabelle:
| Werkzeug | Was es macht | Kann man manuell unlock() aufrufen? | Geeignet für cv.wait() |
|---|---|---|---|
|
„erworben → halte bis Ende des Scopes“ | nein | nein |
|
„erworben → kann freigeben → kann erneut erwerben“ | ja | ja |
Und genau wegen dieser Steuerbarkeit ist unique_lock der Standardpartner der condition_variable.
4. Signatur und Bedeutung von wait(lock, predicate)
Jetzt kommt der zentrale Teil der Vorlesung: das korrekte Wartungsmuster. Die Standardbibliothek bietet eine Überladung von wait, die ein Prädikat (eine boolesche Funktion) annimmt. Diese Überladung existiert genau dafür, dass das Warten korrekt und robust ist. In den Standardunterlagen wird das Thema „condition_variable::wait mit Prädikat“ separat behandelt (auch als Diskussionspunkt zu den Formulierungen der Anforderungen).
Nacktes wait(lock) — Warten ohne Logik
Wenn Sie so schreiben:
cv.wait(lock);
dann sagen Sie im Grunde: „Schlaf mich ein und weck mich irgendwann auf“. Das ist in sehr low-level Protokollen manchmal anwendbar, aber für Lehr- und Anwendungs-Code meistens gefährlich: nach dem Aufwachen müssen Sie selbst prüfen, ob Sie weiterarbeiten dürfen.
wait(lock, predicate) — Warten mit korrekter Prüfung
Die richtige Form sieht so aus:
cv.wait(lock, [&] { return /* Bereitschaftsbedingung */; });
Die Idee ist: „warte, bis die Bedingung wahr wird“. Intern macht die Bibliothek das Äquivalent einer Schleife „prüfen → falls nein schlafen → aufwachen → erneut prüfen“.
Klassische „äquivalente Schreibweise“:
while (!predicate()) {
cv.wait(lock);
}
Und genau deshalb darf das Prädikat keine Nebeneffekte haben: es kann oft aufgerufen werden.
5. Mini-Beispiel: korrekt auf das Flag ready warten
Wenn man beginnt, nebenläufigen Code zu schreiben, möchte man meist mit etwas Einfachem starten: es gibt ein Flag „bereit/nicht bereit“, der Producer setzt true, der Consumer wartet.
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void consumer_waits() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; });
// hier ready == true, und mutex ist wieder erworben
}
Der Producer muss den Zustand dabei unter demselben Mutex ändern:
#include <condition_variable>
#include <mutex>
extern std::mutex m;
extern std::condition_variable cv;
extern bool ready;
void producer_sets_ready() {
{
std::lock_guard<std::mutex> lock(m);
ready = true;
}
cv.notify_one();
}
Beachten Sie die Form: wir ändern ready innerhalb des Blocks, sodass der Mutex garantiert freigegeben wird, und rufen erst danach notify_one() auf.
6. Einbau von wait(lock, predicate) in eine Mini-Anwendung
Stellen wir uns vor, in der letzten Vorlesung (über busy-wait) haben wir bereits einen „Konsolen-Aufgabenverarbeiter“ angefangen: ein Thread fügt Aufgaben hinzu, ein anderer führt sie aus. Bisher konnte der Consumer „spinnen“ und queue.empty() prüfen. Jetzt machen wir es richtig: der Consumer schläft, solange die Queue leer ist.
Zur Vereinfachung sei eine Aufgabe einfach eine Zahl int (z. B. „wie oft eine Zeile ausgeben“ oder „welche Zahl berechnen“).
Zustand der Anwendung: Queue + Synchronisation.
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex g_mutex;
std::condition_variable g_cv;
std::queue<int> g_tasks;
Producer: Aufgabe ablegen und Consumer wecken
Der Producer ist z. B. main, das Eingaben vom Nutzer liest.
#include <condition_variable>
#include <mutex>
#include <queue>
extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;
void push_task(int x) {
{
std::lock_guard<std::mutex> lock(g_mutex);
g_tasks.push(x);
}
g_cv.notify_one();
}
Consumer: warten, bis die Queue nicht leer ist
Hier lebt unser wait(lock, predicate):
#include <condition_variable>
#include <mutex>
#include <queue>
extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;
int pop_task_blocking() {
std::unique_lock<std::mutex> lock(g_mutex);
g_cv.wait(lock, [] { return !g_tasks.empty(); });
int x = g_tasks.front();
g_tasks.pop();
return x;
}
Beachten Sie, wie „menschlich“ das ist: wir schreiben wörtlich „warte, bis die Queue nicht leer ist“. Das ist keine Magie: wait gibt den mutex frei, schläft, und bei Aufwachen erwirbt er den Mutex wieder und überprüft das Prädikat erneut.
7. Wie man Zustand im Prädikat erfasst: [&], [], [this]
Wenn Sie cv.wait(lock, ...) schreiben, verwenden Sie oft eine Lambda. Dann stellt sich die Frage: „Wie erfasse ich was?“. Das ist nicht nur Kosmetik — es beeinflusst Korrektheit und Lesbarkeit.
Ist der Zustand global (wie in unseren kurzen Lehrbeispielen), kann man [] verwenden, weil der Zugriff sowieso auf globale Objekte erfolgt:
g_cv.wait(lock, [] { return !g_tasks.empty(); });
Wenn Sie die Queue jedoch in einem Objekt kapseln (wie wir später noch sehen werden), heißt es oft: this->tasks_, this->closed_, und dann ist es praktisch, this über [&] oder [this] zu erfassen. Im Lehrcode ist [&] meist gut lesbar:
cv_.wait(lock, [&] { return !tasks_.empty(); });
Wichtig ist die Gewohnheit: das Prädikat sollte eine „saubere Zustandsprüfung“ sein, ohne push, ohne pop, ohne Ausgaben auf die Konsole. Es kann häufig aufgerufen werden, und Sie wollen nicht, dass Ihr Code „aufgewacht!“ 200 Mal druckt, nur weil ein Thread zu früh erwacht ist.
8. notify_one(), notify_all() und der Ort des Aufrufs von notify_*
An dieser Stelle ist es wichtig, nicht in Details zu versinken, aber die grundlegende Intuition sollte klar sein. Es gibt zwei Wege, wartende Threads zu wecken:
notify_one() weckt genau einen wartenden Thread. Das passt ideal zu „eine neue Aufgabe → ein Worker wacht auf und nimmt sie“.
notify_all() weckt alle Wartenden. Das ist nützlich, wenn sich der Zustand so verändert hat, dass potenziell viele Threads fortfahren können (oder wenn alle über eine wichtige Änderung informiert werden müssen). Themen wie korrektes Herunterfahren und Fälle, in denen notify_all() notwendig ist, besprechen wir ausführlicher am Ende des Tages.
Übrigens wird in den Standardtexten hervorgehoben, dass rund um condition_variable auf Effizienz und präzise Verhaltensformulierungen geachtet wird.
Jetzt die klassische Frage: „Kann man notify_one() aufrufen, während der mutex noch gehalten wird?“. Technisch ist das möglich, aber meistens ist es praktischer und sauberer, zuerst den Zustand unter dem Mutex zu ändern, dann den Mutex freizugeben (Scope verlassen) und erst danach zu benachrichtigen.
Der Grund ist pragmatisch. Ruft man notify_one() unter dem Mutex auf, wird der geweckte Thread sofort versuchen, den mutex zu erwerben, kann dies aber nicht, weil man ihn selbst noch hält. Formal ist das kein Fehler, es ähnelt jedoch der Situation: „ich klingele an der Tür, halte aber den Schlüssel noch“.
Gutes Lehrmuster:
{
std::lock_guard<std::mutex> lock(m);
ready = true;
}
cv.notify_one();
Solcher Code ist leichter lesbar und wartbar: Zustand ändern und Signal sind visuell getrennt.
9. Noch ein paar Mini-Beispiele von wait(lock, predicate) zum Einprägen
Manchmal ist es hilfreich, dasselbe Muster in verschiedenen Varianten zu sehen. Das ist wie Grammatik lernen: ohne die 10 Beispiele denkt man, man versteht es, aber sicher ist das nicht.
„Tokens“: warten, bis der Zähler größer als null ist
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
int tokens = 0;
void take_token() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return tokens > 0; });
--tokens;
}
„Arbeit vorhanden“: warten, bis mindestens ein Element da ist
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
int pop_one() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return !q.empty(); });
int x = q.front();
q.pop();
return x;
}
„Bereitschaftsprüfung“ und Ausgabe auf die Konsole
#include <iostream>
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void wait_and_print() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; });
std::cout << "Ready!\n"; // Ready!
}
10. Typische Fehler beim Arbeiten mit wait(lock, predicate)
Fehler Nr. 1: cv.wait(lock) verwenden „weil es kürzer ist“.
Das ist zwar kürzer, aber vergleichbar damit, ein if (x) ... zu schreiben, ohne zu verstehen, was x ist und wer es ändert. Wenn Sie auf eine konkrete Bedingung warten (Queue nicht leer, Flag bereit, Tokens vorhanden), ist der richtige Stil das gebundene wait(lock, predicate). Es macht das Warten an einen Zustand gebunden, nicht an ein „mysteriöses Aufwachen“.
Fehler Nr. 2: versuchen, std::lock_guard an wait zu übergeben.
lock_guard kann den Mutex nicht freigeben, und wait muss den Mutex während des Schlafens freigeben. Deshalb benötigt man für das Warten genau std::unique_lock. Wenn der Compiler meckert, haben Sie wahrscheinlich den falschen Lock-Typ gewählt.
Fehler Nr. 3: Prädikat mit Nebeneffekten.
Manche Einsteiger schreiben ein Prädikat, das in die Konsole druckt, einen Zähler erhöht oder sogar versucht, aus der Queue zu pop()en. Das ist ein sehr effizienter Weg, Chaos zu erzeugen: das Prädikat kann oft aufgerufen werden, auch wenn noch keine echte Arbeit da ist. Das Prädikat sollte eine reine boolesche Prüfung des Zustands sein, nicht mehr.
Fehler Nr. 4: Zustand ohne Mutex ändern und hoffen, dass notify_one() „irgendwie synchronisiert“.
notify_one() schützt die Daten nicht und ersetzt keinen mutex. Es weckt nur Threads. Wenn die Zuweisung zu ready oder ein queue.push ohne denselben mutex erfolgt, brechen Sie die Zugriffsdiziplin auf den gemeinsamen Zustand. Im besten Fall bekommen Sie seltene, seltsame Bugs; im schlimmsten Fall ewiges Warten oder Abstürze.
Fehler Nr. 5: den Mutex während „langer Arbeit“ nach dem Aufwachen halten.
Die richtige kritische Sektion sieht typischerweise so aus: „Bedingung geprüft → Daten entnommen → Struktur aktualisiert → Mutex freigegeben“. Wenn Sie nach wait schwere Rechnungen durchführen, Gigabytes an Text ausgeben oder per sleep_for Arbeit simulieren, während der mutex noch gehalten wird, machen Sie aus dem Nebenläufigkeitsprogramm effektiv ein Single-Thread-Programm — und das bringt nur zusätzliche Nerven.
GO TO FULL VERSION