C++ Core Guidelines: Sei dir der Fallen von Bedingungsvariablen bewusst
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.
CP.42: Donât wait without a condition [1]
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 [2] und die Windows API [3] 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 [4]".
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. ( [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
Copyright © 2018 Heise Medien