zurück zum Artikel

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

Rainer Grimm

Man sollte sich der Gefahren von Bedingungsvariablen bewusst sein. Die Regel CP 42 der C++ Core Guidelines lautet schlicht: "Don't wait without a condition."

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.

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:

C++ Core Guidelines: Sei dir der Fallen von Bedingungsvariablen bewußt

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.

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.

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

Anschließende wait-Aufrufe verhalten sich anders.

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

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.

C++ Core Guidelines: Sei dir der Fallen von Bedingungsvariablen bewußt

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 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 [4]".

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. ( [5])


URL dieses Artikels:
https://www.heise.de/-4063822

Links in diesem Artikel:
[1] http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rconc-wait
[2] https://en.wikipedia.org/wiki/POSIX_Threads
[3] https://en.wikipedia.org/wiki/Windows_API
[4] https://www.grimm-jaud.de/index.php/blog/bedingungsvariablen-versus-tasks-zur-synchronisation-von-threads
[5] mailto:rainer@grimm-jaud.de