Aus Linux-Magazin 08/2018

Modernes C++ in der Praxis – Folge 41

Getreu dem Albert Einstein zugeschriebenen Motto “Mache die Dinge so einfach wie möglich, aber nicht einfacher”, beschäftigt sich dieser Beitrag damit, wie sich C++-Quellcode sinnvoll simplifizieren lässt.

Der Artikel liefert den zehnten und letzten Tipp dazu, wie Programmierer modernes C++ am besten umsetzen (Abbildung 1). Er lautet: “Strebe nach Einfachheit.” Aber Vorsicht: Falsch angewandt führt er zu undefiniertem Verhalten. Diese Gefahr lauert zum Beispiel, wenn Entwickler Bedingungsvariablen zu naiv einsetzen.

Abbildung 1: Der letzte der zehn Tipps für C++-Entwickler lautet: "Strebe nach Einfachheit." Zu einfach darf es sich der Entwickler aber auch nicht machen.

Abbildung 1: Der letzte der zehn Tipps für C++-Entwickler lautet: “Strebe nach Einfachheit.” Zu einfach darf es sich der Entwickler aber auch nicht machen.

Richtig, aber auch einfach?

Bedingungsvariablen [1] sind das klassische Mittel der Wahl, um ein einfaches Konzept umzusetzen: zwei Threads zu synchronisieren. Ein Thread übernimmt die Rolle des Senders, ein anderer die des Empfängers. Dabei wartet der Empfänger so lange, bis er Nachricht vom Sender erhalten hat, um seine Arbeit umzusetzen. Dieses Muster lässt sich direkt im C++-Quellcode umsetzen (Listing 1).

Listing 1

Sender und Empfänger mit Bedingungsvariablen

01 #include <condition_variable>
02 #include <iostream>
03 #include <thread>
04
05 std::mutex mutex_;
06 std::condition_variable condVar;
07
08 bool dataReady{false};
09
10 void waitingForWork(){
11     std::cout << "Waiting " << std::endl;
12     std::unique_lock<std::mutex> lck(mutex_);
13     condVar.wait(lck, []{ return dataReady; });
14     std::cout << "Running " << std::endl;
15 }
16
17 void setDataReady(){
18     {
19         std::lock_guard<std::mutex> lck(mutex_);
20         dataReady = true;
21     }
22     std::cout << "Data prepared" << std::endl;
23     condVar.notify_one();
24 }
25
26 int main(){
27
28   std::cout << std::endl;
29
30   std::thread t1(waitingForWork);
31   std::thread t2(setDataReady);
32
33   t1.join();
34   t2.join();
35
36   std::cout << std::endl;
37
38 }

Und so funktioniert das Synchronisieren: Das Programm besitzt zwei Kind-Threads: »t1« und »t2«. Sie erhalten ihr Arbeitspaket über die Funktionen »waitingForWork()« sowie »setDataReady()« in den Zeilen 30 und 31. Über die Bedingungsvariable »condVar« (»condVar.notify_one()«) sendet »setDataReady()« die Nachricht, dass die Funktion mit der Vorbereitung der Arbeit fertig ist. Besitzt der Thread »t1« den Lock, wartet er via »condVar.wait(lck,[] return dataReady; )« auf eine Benachrichtigung.

Sowohl der Sender als auch der Empfänger der Nachricht benötigen ein Lock. Im Falle des Senders genügt ein einfaches »std::lock_guard« [2], da er einen Mutex nur ein einziges Mal lockt und wieder freigibt. Der Empfänger benötigt hingegen ein Unique Lock (»std::unique_lock«, [3]), da er gegebenenfalls einen Mutex mehrmals locken und wieder freigeben muss. Die Ausgabe des Programms zeigt Abbildung 2.

Abbildung 2: Bedingungsvariablen &ndash; richtig eingesetzt.

Abbildung 2: Bedingungsvariablen – richtig eingesetzt.

Zwar verhält sich das Programm wie erwartet. Die Implementierung der Funktionen »waitingForWork()« und »setDataReady()« fällt aber deutlich komplizierter aus als gedacht. Der Grund ist sehr einfach: Bedingungsvariablen besitzen kein Gedächtnis und werden daher mit Vorliebe zu Opfern von Spurious und Lost Wakeups.

Spurious Wakeup und Lost Wakeup

Das Phänomen des Lost Wakeup besteht darin, dass der Sender seine Benachrichtigung bereits schickt, bevor sich der Sender im Wartezustand befindet. Als Konsequenz geht die Benachrichtigung verloren und der Empfänger wartet und wartet.

Im Gegensatz zum Lost Wakeup passiert es beim Spurious Wakeup, dass der Empfänger der Nachricht aufwacht, obwohl der Sender gar keine Benachrichtigung geschickt hat.

Als Schutz gegen diese beiden Phänomene benötigt der Empfänger ein zusätzliches Prädikat, das er prüft. Dies zu implementieren, ist aber alles andere als einfach: Führt das Programm »wait()« zum ersten Mal aus, sind die folgenden Schritte erforderlich:

  • Der Aufruf »wait()« lockt zunächst den Mutex.
  • Er prüft dann, ob das Prädikat »[] return dataReady;« auch »true« ergibt.
  • Besitzt das Prädikat den Wert »true«, fährt der Thread fort.
  • Ergibt sich »false« als Wert, gibt die Bedingungsvariable als Reaktion den Mutex frei und kehrt in den Wartezustand zurück.

Anschließende »wait()«-Aufrufe verhalten sich hingegen anders, wenn sie eine Benachrichtigung erhalten:

  • Der wartende Thread lockt seinen Mutex.
  • Er prüft, ob das Prädikat »[] return dataReady;« auch »true« ergibt.
  • Ist das der Fall, fährt der Thread weiter fort.
  • Trägt das Prädikat den Wert »false«, gibt die Bedingungsvariable den Mutex frei und geht wieder in den Wartezustand.

An dieser Stelle stellt sich die Frage: Muss die Synchronisation mit Bedinungungsvariablen so kompliziert sein? Leider scheint es so.

So einfach wie möglich, aber nicht einfacher

Was passiert, wenn der Entwickler das Synchronisieren der Bedingungsvariablen auf das Nötigste reduziert? Einen Versuch ist es wert. Dafür hat der Autor die Variable »dataReady«, die das Gedächtnis der Bedingungsvariablen repräsentiert, entfernt. Sofort liest sich das Programm viel einfacher (Listing 2). Nun kommt der »wait()«-Aufruf in Zeile 11 ganz ohne Prädikat aus – so sieht Fortschritt aus. Leider enthält das Programm jetzt eine Race Condition, die sich bereits bei seiner ersten Ausführung in Form eines Deadlock zeigt (Abbildung 3).

Listing 2

Bedingungsvariable ohne Prädikat

01 #include <condition_variable>
02 #include <iostream>
03 #include <thread>
04
05 std::mutex mutex_;
06 std::condition_variable condVar;
07
08 void waitingForWork(){
09     std::cout << "Waiting " << std::endl;
10     std::unique_lock<std::mutex> lck(mutex_);
11     condVar.wait(lck);
12     std::cout << "Running " << std::endl;
13 }
14
15 void setDataReady(){
16     std::cout << "Data prepared" << std::endl;
17     condVar.notify_one();
18 }
19
20 int main(){
21
22   std::cout << std::endl;
23
24   std::thread t1(waitingForWork);
25   std::thread t2(setDataReady);
26
27   t1.join();
28   t2.join();
29
30   std::cout << std::endl;
31
32 }

Abbildung 3: Bedingungsvariable ohne Pr&auml;dikat.

Abbildung 3: Bedingungsvariable ohne Prädikat.

Der Sender sendet in Zeile 17 (»condVar.notify_one()«) seine Benachrichtigung, bevor der Empfänger bereit ist, diese anzunehmen. Also legt sich der Empfänger in einen Dornröschenschlaf. Okay, Lektion gelernt – das Prädikat ist unbedingt notwendig. Das Programm in Listing 1 muss sich aber doch vereinfachen lassen!

Aber nicht einfacher – die Zweite

Ein zweiter intensiver Blick auf Listing 1 erspäht Optimierungspotenzial. Bei der Variablen »dataReady« handelt es sich um einen Wahrheitswert. Eigentlich sollte das genügen, um diesen als atomar zu erklären. Gesagt, getan: Listing 3 verfolgt diesen Ansatz.

Listing 3

Bedingungsvariable mit atomarer Variablen als Prädikat

01 #include <atomic>
02 #include <condition_variable>
03 #include <iostream>
04 #include <thread>
05
06 std::mutex mutex_;
07 std::condition_variable condVar;
08
09 std::atomic<bool> dataReady{false};
10
11 void waitingForWork(){
12     std::cout << "Waiting " << std::endl;
13     std::unique_lock<std::mutex> lck(mutex_);
14     condVar.wait(lck, []{ return dataReady.load(); });
15     std::cout << "Running " << std::endl;
16 }
17
18 void setDataReady(){
19     dataReady = true;
20     std::cout << "Data prepared" << std::endl;
21     condVar.notify_one();
22 }
23
24 int main(){
25
26   std::cout << std::endl;
27
28   std::thread t1(waitingForWork);
29   std::thread t2(setDataReady);
30
31   t1.join();
32   t2.join();
33
34   std::cout << std::endl;
35
36 }

Das Programm liest sich in der Folge bereits deutlich einfacher als in Listing 1, denn es schützt »dataReady« nun nicht mehr durch ein Lock. Leider ist es zugleich einen Tick zu einfach geschrieben, da auch hier eine Race Condition lauert, die ein Deadlock nach sich ziehen kann. Zwar handelt es sich bei »dataReady« um eine atomare Variable, doch der »wait()«-Ausdruck in Zeile 14 (»condVar.wait(lck, []{ return dataReady.load(); });«) erweist sich als deutlich komplexer, als es anfänglich erscheint. Tatsächlich verhält sich der Ausdruck äquivalent zu den Zeilen aus Listing 4.

Listing 4

Der wait()-Ausdruck

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

Modifiziert Thread »t2« nun »dataReady«, ohne dass ein Mutex es schützt, wird dies nicht richtig synchronisiert veröffentlicht. Zugegeben, die Begründung ist relativ schwierig zu verdauen, denn was heißt “nicht richtig synchronisiert”?

Um sich diesen Zustand vorzustellen, hilft es, wenn der Entwickler annimmt, dass die Benachrichtigung geschickt wird, obwohl sich die Bedingungsvariable »condVar« nicht im Wartezustand befindet. Dieser Zustand hat zur Folge, dass der Thread sich zwischen den zwei Anweisungen in der Zeile mit dem Ausdruck »time window« befindet. Dadurch geht die Benachrichtigung verloren und der Thread kehrt in einen Wartezustand zurück. Das Problem: In diesem Fall wartet er ewig.

Schützt hingegen – wie in Listing 1 – ein Mutex die Variable »dataReady«, kann die Benachrichtigung nicht verloren gehen, da der Empfänger diese nur im Wartezustand erhält.

Lautet das ernüchternde Ergebnis nach verschiedenen Versuchen also, dass sich das Programm in Listing 1 nicht weiter optimieren lässt? Nicht unbedingt. Optimierungen sind möglich, allerdings nicht mit Hilfe von Bedingungsvariablen.

Wirklich einfach

Die Rettung naht in der Gestalt eines bekannten Pärchens aus einem Promise [4] und einem Future [5]. Dabei schickt der Promise die Benachrichtigung, auf die der Future wartet. Dieser Weg reduziert die Synchronisation auf das Nötigste (Listing 5).

Listing 5

Threads mit Promise und Future synchronisieren

01 #include <future>
02 #include <iostream>
03 #include <thread>
04 #include <utility>
05
06 std::mutex mutex_;
07 std::condition_variable condVar;
08
09 std::atomic<bool> dataReady{false};
10
11 void waitingForWork(std::future<void> fut){
12     std::cout << "Waiting " << std::endl;
13     fut.get();
14     std::cout << "Running " << std::endl;
15 }
16
17 void setDataReady(std::promise<void> prom){
18     std::cout << "Data prepared" << std::endl;
19     prom.set_value();
20 }
21
22 int main(){
23
24   std::cout << std::endl;
25
26   std::promise<void> sendReadyPromise;
27   std::future<void> waitForFuture = sendReadyPromise.get_future();
28
29   std::thread t1(waitingForWork, std::move(waitForFuture));
30   std::thread t2(setDataReady, std::move(sendReadyPromise));
31
32   t1.join();
33   t2.join();
34
35   std::cout << std::endl;
36
37 }

Das ist alles? Der Thread »t1« bekommt sein Arbeitspaket und seinen Future, der Thread »t2« sein Arbeitspaket und seinen Promise. Da das Programm Future und Promise nicht kopieren kann, verschiebt es sie in den Zeilen 29 und 30. Der Future wartet in Zeile 13 auf die Benachrichtigung durch den Promise in Zeile 19. Promise und Future sind dabei völlig immun gegen Lost und Spurious Wakeups und besitzen einen geschützten Kommunikationskanal. Daher brauchen sie auch keinen Mutex.

Wie geht’s weiter?

Die letzten zehn Artikel stellten zehn Merksätze in den Fokus, unter deren Berücksichtigung Entwickler gutes C++ schreiben. Doch wie geht es weiter? Es gibt viele Regelwerke, die danach streben, Entwicklern gutes C++ zu vermitteln. Zu den bekanntesten zählen wohl das weltweit eingesetzte Misra-C++-Regelwerk [6], die deutlich moderneren Autosar-C++14-Regeln [7] sowie die C++ Core Guidelines [8], die im Zuge der C++-Standardisierung entstehen. Einen genauen Blick auf diese Regelwerke wirft der nächste Artikel.

Der Autor

Rainer Grimm ist Trainer für C++ und Python. Seine zahlreichen C++-Bücher, zuletzt “The C++ Standard Library” und “Concurrency with modern C++”, sind bei O’Reilly und Leanpub erschienen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben