C++ Core Guidelines: Sei dir der Fallen von Bedingungsvariablen bewusst

Modernes C++  –  7 Kommentare

Heute schreibe ich einen gruseligen Artikel zu Bedingungsvariablen. Du solltest dir ihrer Gefahren bewusst sein. Die Regel CP.42 der C++ Core Guidelines lautet schlicht: "Don't wait without a condition."

Zuerst einmal gilt: Bedingungsvariablen setzen ein sehr einfaches Konzept um. Ein Thread bereitet eine Arbeit vor und sendet seine Benachrichtigung, auf die ein anderer Thread wartet. Das kann doch nicht so schwierig sein! Dachte ich auch zuerst. Hier ist die einzige Regel für den heutigen Artikel.

CP.42: Don’t wait without a condition

Gleich im ersten Satz präsentiert die Regel die Begründung: "A wait without a condition can miss a wakeup or wake up simply to find that there is no work to do." Was heißt das? Bedingungsvariablen können Opfer von zwei sehr ernsthaften Fallen sein: Lost Wakeup und Spurious Wakeup. Doch was ist ein Lost Wakeup und ein Spurious Wakeup? Lost Wakeup steht für eine Benachrichtigung, die verloren geht, und Spurious Wakeup stellt eine Benachrichtigung dar, die nicht vom erwarteten Sender stammt. Der Grund dafür ist naheliegend. Bedingungsvariablen besitzen kein Gedächtnis.

Bevor ich auf die Fallen von Bedingungsvariablen eingehe, möchte ich sie zuerst einmal richtig einsetzen. Hier ist das Kochrezept, um Bedingunsvariablen richtig einzusetzen:

// conditionVariables.cpp

#include <condition_variable>
#include <iostream>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar;

bool dataReady{false};

void waitingForWork(){
std::cout << "Waiting " << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck, []{ return dataReady; }); // (4)
std::cout << "Running " << std::endl;
}

void setDataReady(){
{
std::lock_guard<std::mutex> lck(mutex_);
dataReady = true;
}
std::cout << "Data prepared" << std::endl;
condVar.notify_one(); // (3)
}

int main(){

std::cout << std::endl;

std::thread t1(waitingForWork); // (1)
std::thread t2(setDataReady); // (2)

t1.join();
t2.join();

std::cout << std::endl;

}

Wie funktioniert die Synchronisation? Das Programm besitzt zwei Kind-Threads: t1 und t2. Diese erhalten ihr Arbeitspaket waitingForWork und setDataReady in den Zeilen (1) und (2). setDataReady sendet seine Nachricht, dass er mit der Vorbereitung der Arbeit fertig ist mithilfe der Bedingungsvariable condVar: condVar.notify_one()(Zeile 3). Während der Thread t1 den Lock besitzt, wartet er auf seine Benachrichtigung: condVar.wait(lck,[] return dataReady; ) (Zeile 4). Sowohl der Sender als auch der Empfänger der Nachricht benötigen einen Lock. Im Falle des Senders ist ein einfacher std::lock_guard ausreichend, da er einen Mutex nur ein einziges Mal lockt und wieder freigibt. Der Empfänger benötigt hingegen ein std::unique_lock, da er gegebenenfalls einen Mutex mehrmals locken und wieder freigeben muss.

Das Programm besitzt die erwartete Ausgabe:

Vermutlich wunderst du dich: Warum benötigt der wait-Aufruf ein Prädikat, denn es lässt sich auch ohne diesen verwenden? Dieser Ablauf wirkt viel zu kompliziert für eine solch einfache Aufgabe wie die Synchronisation von Threads.

Jetzt komme ich zu dem fehlenden Gedächtnis von Bedingungsvariablen und den zwei Phänomenen Lost Wakeup und Spurious Wakeup zurück.

Lost Wakeup und Spuriour Wakeup

  • Lost Wakeup: Das Phänomen des Lost Wakeup ist, dass der Sender seine Benachrichtigung schickt, bevor der Empfänger im Wartezustand ist. Als Konsequenz geht dadurch die Benachrichtigung verloren und der Empfänger wartet und wartet und ...
  • Spurious Wakeup: Hier passiert es, dass der Empfänger der Nachricht aufwacht, obwohl der Sender keine Benachrichtigung geschickt hat. Zumindest POSIX Threads und die Windows API können Opfer dieses Phänomens sein.

Als Schutz gegen diese beiden Phänomene benötigt der Empfänger ein zusätzliches Prädikat als Gedächtnis, das er prüft. Damit beginnt die Komplexität.

Der wait-Arbeitsablauf

Falls wait zum ersten Mal ausgeführt wird, finden die folgenden Schritte statt:

  • Der Aufruf wait lockt den Mutex und prüft, ob das Prädikat [] return dataReady; true ergibt.
    • Falls das Prädikat true ergibt, fährt der Thread weiter fort.
    • Falls das Prädikat false ergibt, gibt die Bedingungsvariable den Mutex frei und begibt sich wieder in den Wartezustand.

Anschließende wait-Aufrufe verhalten sich anders.

  • Der wartende Thread erhält eine Benachrichtigung.
  • Er lockt seinen Mutex und prüft, ob das Prädikat [] return dataReady; true ergibt.
    • Falls das Prädikat true ergibt, fährt der Thread weiter fort.
    • Falls das Prädikat false ergibt, gibt die Bedingungsvariable den Mutex frei und begibt sich wieder in den Wartezustand.

Muss die Synchronisation mit Bedingungungsvariablen so kompliziert sein? Leider ja!

Ohne ein Prädikat

Was passiert, wenn ich das Prädikat (Condition) im letzten Beispiel entferne?

// conditionVariableWithoutPredicate.cpp

#include <condition_variable>
#include <iostream>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar;

void waitingForWork(){
std::cout << "Waiting " << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck); // (1)
std::cout << "Running " << std::endl;
}

void setDataReady(){
std::cout << "Data prepared" << std::endl;
condVar.notify_one(); // (2)
}

int main(){

std::cout << std::endl;

std::thread t1(waitingForWork);
std::thread t2(setDataReady);

t1.join();
t2.join();

std::cout << std::endl;

}

Nun kommt der wait-Aufruf in Zeile (1) gänzlich ohne Prädikat aus. Wenn das kein Fortschritt ist! Leider besitzt das Programm jetzt eine Race Condition, die sich bereits bei seiner ersten Ausführung als Deadlock entpuppt.

Der Sender sendet in Zeile (2) (condVar.notify_one()) seine Benachrichtigung, bevor der Empfänger bereit ist, diese anzunehmen. Damit wartet der Empfänger für immer.

Okay. Lektion gelernt. Das Prädikat ist unbedingt notwendig. Das Programm conditionVariables.cpp muss sich doch vereinfachen lassen.

Ein atomares Prädikat

Ein scharfer Blick auf conditionVariable.cpp verspricht Optimierungspotenzial. Die Variable dataReady ist ein Wahrheitswert. Daher sollte es ausreichen, diese als atomare Variable zu erklären. Sollte!

Hier ist die nächste Variante:

// conditionVariableAtomic.cpp

#include <atomic>
#include <condition_variable>
#include <iostream>
#include <thread>

std::mutex mutex_;
std::condition_variable condVar;

std::atomic<bool> dataReady{false};

void waitingForWork(){
std::cout << "Waiting " << std::endl;
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck, []{ return dataReady.load(); }); // (1)
std::cout << "Running " << std::endl;
}

void setDataReady(){
dataReady = true;
std::cout << "Data prepared" << std::endl;
condVar.notify_one();
}

int main(){

std::cout << std::endl;

std::thread t1(waitingForWork);
std::thread t2(setDataReady);

t1.join();
t2.join();

std::cout << std::endl;

}

Das Programm ist bereits deutlich einfacher, denn dataReady muss nicht durch einen Lock geschützt werden. Leider ist es zu einfach, da eine Race Condition lauert, die zu einem Deadlock führen kann. Warum? dataReady ist doch eine atomare Variable! Stimmt.

Der wait-Ausdruck in Zeile (1) (condVar.wait(lck, []{ return dataReady.load(); });) ist deutlich komplexer, als er scheint. Er ist äquivalent zu den folgenden Zeilen:

std::unique_lock<std::mutex> lck(mutex_);
while ( ![]{ return dataReady.load(); }() {
// time window (1)
condVar.wait(lck);
}

Falls Thread t2 dataReady modifiziert, obwohl es nicht durch einen Mutex geschützt ist, wird dies nicht richtig synchronisiert veröffentlicht. Was heißt das: veröffentlicht, aber nicht richtig synchronisiert. Dazu hilft es anzunehmen, dass die Benachrichtigung geschickt wird, obwohl die Bedingungsvariable condVar nicht im Wartezustand ist. Das bedeutet, dass der Thread sich zwischen den zwei Anweisungen in Zeile (1) befindet. Damit geht die Benachrichtigung verloren und der Thread in den Wartezustand zurück. In diesem Fall wartet er ewig. Falls dataReady durch einen Mutex wie im ersten Beispiel conditionVariable.cpp geschützt wird, kann die Benachrichtigung nicht verloren gehen, da der Empfänger diese nur in seinem Wartezustand erhält.

Wenn das nicht ernüchternd war. Lässt sich das Programm conditionVariables.cpp nicht vereinfachen? Doch. Leider aber nicht mit Bedingungsvariablen, sondern mit einem Promise- und Future-Paar. Die Details dazu gibt es in meinen Artikel "Bedingungsvariablen versus Tasks zur Synchronisation von Threads".

Wie geht's weiter?

Nun bin ich fast fertig mit den Regeln zur Concurrency. Die Regeln zur Parallelität, Message Passing und Vektorisierung besitzen keinen Inhalt, daher werde ich sie übergehen und im nächsten Artikel hauptsächlich über die Lock-freie Programmierung schreiben.