Aus Linux-Magazin 06/2012

Modernes C++ in der Praxis – Folge 4

Eifrig machen sich mehrere Threads zeitgleich an die Arbeit. Doch ohne gegenseitige Abstimmung produzieren sie nur Chaos. Mit Hilfe von Daemon-Threads, Mutexen und leistungsfähigen Locking-Mechanismen sorgt der C++11-Programmierer wieder für Ordnung.

Ein Thread lässt sich in C++11 leicht mit seinem Arbeitspaket ausstatten und starten [1]. Doch der Programmierer muss Sorgfalt walten lassen, damit der Vaterthread mit seinen Kinderthreads koordiniert zusammenarbeitet: Er muss sicherstellen, dass der Vater mindestens so lange lebt wie seine Kinder, und zudem die Zugriffe auf die gemeinsam genutzten Variablen schützen. Dass C++11 dafür das passende Handwerkszeug anbietet, zeigt dieser Artikel.

Wie sich in Listing 1 nachvollziehen lässt, geht das Erzeugen und Ausführen eines Thread in C++11 recht schnell von der Hand. Den Umgang mit den Namensräumen in den Codebeispielen erläutert der Kasten “Explizite »using« -Deklaration”. Die Zeile 16 des Listings erzeugt den Thread »child« . Dessen Aufgabe besteht darin, seinen Namen und seine Identität auf die Standardausgabe »cout« zu schreiben.

Listing 1

Der Vaterthread wartet nicht auf sein Kind

01 #include <iostream>
02 #include <thread>
03
04 using std::cout;
05 using std::endl;
06
07 using std::thread;
08 using std::this_thread::get_id;
09
10 int main(){
11
12   cout << endl;
13
14   cout << "father: " << get_id() << endl;
15
16   thread child([]{ cout << "child:  " << get_id() << endl; } );
17
18   cout << endl;
19
20 };

Explizite using-Deklaration

Die Codebeispiele der C++11-Serie machen die Funktionen der Namensräume auf eine spezielle Weise bekannt. Sie setzen »using« -Deklarationen ein, etwa Listing 1 für »std« in den Zeilen 4 bis 8. Dadurch wird der Sourcecode deutlich einfacher lesbar.

Zudem stellt das explizite Einführen der Funktion »get_id()« durch »using std::this_thread::get_id;« sicher, dass die Zuordnung zwischen der Funktion und dem Namensraum eindeutig ist. Der voll qualifizierte Aufruf von »get_id()« lautet somit »std::this_thread::get_id()« .

Übersetzt und ausgeführt offenbart das Programm aber Probleme (Abbildung 1): Zum einen schreibt nur der Vater seine Identität auf die Konsole, dem Kind gelingt dies nicht. Zum anderen beendet sich das Programm mit »std::terminate()« . Das liegt daran, dass der Vater nicht wartet, bis der Kinderthread seine Aufgabe erledigt hat. In Listing 2 dagegen stellt der Aufruf von »join()« in Zeile 18 sicher, dass der Vaterthread lange genug lebt. Damit verhält sich das Programm wie erwartet (Abbildung 2).

Listing 2

Der Vaterthread wartet auf sein Kind

01 #include <iostream>
02 #include <thread>
03
04 using std::cout;
05 using std::endl;
06
07 using std::thread;
08 using std::this_thread::get_id;
09
10 int main(){
11
12   cout << endl;
13
14   cout << "father: " << get_id() << endl;
15
16   thread child([]{ cout << "child:  " << get_id() << endl; } );
17
18   child.join();
19
20   cout << endl;
21
22 };
Abbildung 1: Der Kindthread kommt nicht zum Schreiben, bevor der Vaterthread sich beendet.

Abbildung 1: Der Kindthread kommt nicht zum Schreiben, bevor der Vaterthread sich beendet.

Bei Anwendungen mit mehr als einem Thread muss sich der Programmierer unbedingt Gedanken über die Lebenszeit des Vaterthread und seiner Kinder machen. Er hat entweder dafür zu sorgen, dass der Vater mindestens so lange lebt wie die Kinder, wofür ein Aufruf der Methode »join()« für jeden Thread sorgt. Oder er macht die Kinder vom Vaterthread unabhängig, was durch Aufrufen von »detach()« gelingt. Die Detach-Methode erzeugt einen so genannten Daemon-Thread, der im Hintergrund läuft und seinen Vater überleben kann [2].

Alle durcheinander

Nach wenigen Anpassungen schreibt das erste Multithreading-Programm definiert auf die Konsole, die hier die von allen Threads gemeinsam genutzte Variable darstellt. Definiert? Listing 2 provoziert im Grunde undefiniertes Verhalten. Doch wie es der Zufall will (oder die Threads es wollen), tritt dieses Verhalten im Beispiel nicht auf. Für Klarheit lässt sich aber sorgen. Listing 3 gibt den Threads deutlich mehr zu tun. Der Boss (Vaterthread) koordiniert seine sechs Arbeiter (Kinderthreads) namens Herb, Andrei, Scott, Bjarne, Andrew und David. Jeder Arbeiter muss drei Arbeitspakete (Zeilen 20 bis 25) ausführen, die er in Form eines Funktionsobjekts (Zeilen 39 bis 44) erhält. Ein Funktionsobjekt ist ein Objekt, das sich wie eine Funktion aufrufen lässt. Zeile 19 erreicht dies durch das Überladen des Klammeroperators.

Listing 3

Alle Arbeiter rufen durcheinander

01 #include <chrono>
02 #include <iostream>
03 #include <thread>
04
05 using std::cout;
06 using std::endl;
07
08 using std::string;
09
10 using std::chrono::milliseconds;
11
12 using std::thread;
13 using std::this_thread::sleep_for;
14
15 class Worker{
16 public:
17   Worker(string n):name(n){};
18
19     void operator() (){
20       for (int i= 1; i <= 3; ++i){
21         // begin work
22         sleep_for(milliseconds(200));
23         // end work
24         cout << name << ": " << "Work " << i << " done !!!" << endl;
25       }
26
27     }
28 private:
29   string name;
30 };
31
32
33 int main(){
34
35   cout << endl;
36
37   cout << "Boss: Let's start working.\n\n";
38
39   thread herb= thread(Worker("Herb"));
40   thread andrei= thread(Worker("  Andrei"));
41   thread scott= thread(Worker("    Scott"));
42   thread bjarne= thread(Worker("      Bjarne"));
43   thread andrew= thread(Worker("        Andrew"));
44   thread david= thread(Worker("          David"));
45
46   herb.join();
47   andrei.join();
48   scott.join();
49   bjarne.join();
50   andrew.join();
51   david.join();
52
53   cout << "\n" << "Boss: Let's go home." << endl;
54
55   cout << endl;
56
57 }

Jedes Arbeitspaket benötigt eine Fünftelsekunde (Zeile 22). Hat ein Arbeiter das soundsovielte Arbeitspaket geschafft, ruft er seinen Namen, damit der Chef im Bilde bleibt. Beispielsweise schreit Herb beim dritten Arbeitspaket: »Herb: Work 3 done !!!« . Der Boss bestimmt in Zeile 37 den Arbeitsbeginn und in Zeile 53, dass das Tagwerk vollbracht ist.

Abbildung 3 zeigt, welches Durcheinander beim Ausführen auf dem Terminal landet. Die Arbeiter rufen in ihrem Arbeitseifer die Benachrichtigungen vollkommen wild durcheinander. “Was für ein Chaos! Das muss morgen besser werden”, denkt sich der Boss. Er vereinbart mit den Mitarbeitern für den nächsten Tag, dass jeder seine Benachrichtigung vollständig beenden darf, bevor der nächste an der Reihe ist. Listing 4 bringt ein kleines bisschen mehr Disziplin ins Spiel, der Vorgesetzte ist zu jedem Zeitpunkt im Bilde (Abbildung 4).

Listing 4

Jeder Arbeiter lässt seine Kollegen ausreden

01 #include <chrono>
02 #include <iostream>
03 #include <thread>
04
05 using std::cout;
06 using std::endl;
07
08 using std::string;
09
10 using std::chrono::milliseconds;
11
12 using std::mutex;
13 using std::thread;
14 using std::this_thread::sleep_for;
15
16 mutex coutMutex;
17
18 class Worker{
19 public:
20   Worker(string n):name(n){};
21
22     void operator() (){
23       for (int i= 1; i <= 3; ++i){
24         // begin work
25         sleep_for(milliseconds(200));
26         // end work
27         coutMutex.lock();
28         cout << name << ": " << "Work " << i << " done !!!" << endl;
29         coutMutex.unlock();
30       }
31
32     }
33 private:
34   string name;
35 };
36
37
38 int main(){
39
40   cout << endl;
41
42   cout << "Boss: Let's start working." << "\n\n";
43
44   thread herb= thread(Worker("Herb"));
45   thread andrei= thread(Worker(" Andrei"));
46   thread scott= thread(Worker("  Scott"));
47   thread bjarne= thread(Worker("   Bjarne"));
48   thread andrew= thread(Worker("    Andrew"));
49   thread david= thread(Worker("     David"));
50
51   herb.join();
52   andrei.join();
53   scott.join();
54   bjarne.join();
55   andrew.join();
56   david.join();
57
58   cout << "\n" << "Boss: Let's go home." << endl;
59
60   cout << endl;
61
62 }
Abbildung 3: Ohne Koordination der Threads rufen die Arbeiter wild durcheinander.

Abbildung 3: Ohne Koordination der Threads rufen die Arbeiter wild durcheinander.

Abbildung 4: Nur ein Arbeiter auf einmal darf rufen, die anderen dürfen ihm währenddessen nicht ins Wort fallen.

Abbildung 4: Nur ein Arbeiter auf einmal darf rufen, die anderen dürfen ihm währenddessen nicht ins Wort fallen.

Sperren per Mutex

Der Unterschied von Listing 4 zum vorigen steckt in den Zeilen 27 und 29. Der Code verwendet dort in »coutMutex.lock()« einen Mutex, um den Zugriff auf »std::cout« zu sperren, und »coutMutex.unlock()« , um den Mutex wieder freizugeben. Dadurch ist sichergestellt, dass nur ein Thread auf »std::cout« schreiben darf – anders ausgedrückt, dass »std::cout« als gemeinsam genutzte Variable geschützt ist.

Erhält ein Thread den Mutex nicht, muss er warten bis dieser frei wird. Genau dies Verhalten findet sich in dem Namen der Datenstruktur wieder: Ein Mutex stellt Mutual Exclusion (gegenseitiges Ausschließen) sicher. Neben diesem einfachen Mutex gibt es in C++11 noch rekursive Mutexe und Mutexe mit Zeitgrenze. Genauer lässt sich das auf der Wikiseite zum C++-Standard [3] nachlesen.

Der Boss der Arbeiter ist nun zufrieden, der Autor dieses Artikels aber nicht. Wie sich später noch zeigen wird, sollte der Programmierer Mutexe nicht direkt anwenden, sondern in einem Lock kapseln. Denn was passiert, wenn der Arbeiter »David« beim Ausrufen seiner Meldung – wir wollen es nicht hoffen – stirbt? Alle Arbeiter warten vergebens darauf, dass er seine Nachricht beendet. »David« ist damit – bildlich gesprochen – Besitzer des Lock und gibt es nicht mehr frei. So hindert er alle anderen Arbeiter daran, ihre Nachricht auszurufen und damit den Rest ihrer Arbeit zu erledigen. Die ganze Arbeit kommt zum Erliegen – ein so genanntes Deadlock tritt ein.

Ein Deadlock beschreibt eine Situation, in der sich mindestens zwei Prozesse so blockieren, dass keiner mehr einen Fortschritt macht. Die klassische Situation für ein Deadlock besteht darin, dass zwei Threads jeweils dieselben zwei Locks benötigen und diese in verschiedener Reihenfolge anfordern. Grafisch ist das in Abbildung 5 dargestellt: Thread 1 fordert Lock 1, und Thread 2 fordert Lock 2 erfolgreich an und hält es. Damit Thread 1 weiter fortschreiten kann, benötigt er Lock 2, das derzeit aber Thread 2 besitzt. Ähnlich verhält es sich bei Thread 2: Damit er fortfahren kann, benötigt er das bereits vergebene Lock 1. Da kein Thread sein Lock freiwillig abgibt, blockieren sich die beiden Threads gegenseitig.

Abbildung 5: Deadlock: Jeder Thread hält ein Lock und wartet darauf, dass der andere seines freigibt.

Abbildung 5: Deadlock: Jeder Thread hält ein Lock und wartet darauf, dass der andere seines freigibt.

Deadlock auflösen

C++11 bietet aber nun mit »std::lock« eine Lösung für dieses Problem an, denn mit dessen Hilfe lassen sich mehrere Locks vom Typ »std::unique_lock« atomar locken. Dies bewirkt, dass ein Thread entweder alle oder gar kein Lock erhält. Das ist auch der Grund, warum der Programmierer einen Mutex nicht direkt verwenden, sondern in ein Lock verpacken sollte.

Der leicht modifizierte Worker in Listing 5 zeigt, dass »lock_guard« in Zeile 10 den »coutMutex« kapselt. Dabei bindet dieser seinen Mutex im Konstruktoraufruf und gibt ihn automatisch im Destruktoraufruf wieder frei. Dieses bekannte und bewährte C++-Idiom ist als “Resource Acquisition Is Initialization” (RAII, [4]) bekannt. Der Destruktor von »lock_guard« wird genau dann aufgerufen, wenn dieser seinen Gültigkeitsbereich verlässt. Das tritt regulär beim Beenden der For-Schleife oder auch irregulär beim vorzeitigen Ableben des Thread ein.

Listing 5

Sperren durchlock_guard

01 class Worker{
02 public:
03   Worker(string n):name(n){};
04
05     void operator() (){
06       for (int i= 1; i <= 3; ++i){
07         // begin work
08         sleep_for(milliseconds(200));
09         // end work
10         lock_guard<mutex>coutGuard(coutMutex);
11         cout << name << ": " << "Work " << i << " done !!!" << endl;
12       }
13
14     }
15 private:
16   string name;
17 };

Listing 6 stellt eine Variation des Arbeiters aus Listing 5 dar. Um den Mutex in Listing 5 zu kapseln, erzeugt jeder Aufruf in Zeile 10 ein »lock_guard« -Objekt. Dies ist nötig, da sich »lock_guard« nur durch einen Mutex instanzieren lässt.

Listing 6

Sperren durch unique_lock

01 class Worker{
02 public:
03   Worker(string n):name(n){};
04
05     void operator() (){
06       unique_lock<mutex>coutGuard(coutMutex,defer_lock);
07       for (int i= 1; i <= 3; ++i){
08         // begin work
09         sleep_for(milliseconds(200));
10         // end work
11         coutGuard.lock();
12         cout << name << ": " << "Work " << i << " done !!!" << endl;
13         coutGuard.unlock();
14       }
15
16     }
17 private:
18   string name;
19 };

Kluge Sperre

Dagegen ist »unique_lock« (Listing 6, Zeile 6) deutlich leistungsfähiger: Es wird nur einmal instanziert und bindet seinen Mutex. Im Gegensatz zum »lock_guard« sperrt es den Mutex nicht im Konstruktoraufruf. Dafür sorgt »defer_lock« in Zeile 6. Durch den Aufruf »coutGuard.lock()« in Zeile 11 sperrt das »unique_lock« seinen Mutex und gibt ihn durch »lockGuard.unlock()« in Zeile 13 wieder frei. Die Gefahr eines Deadlock besteht hier nicht, da »unique_lock« ein lokales Objekt in der Funktion »operator« in Zeile 5 ist und somit sein Mutex automatisch freigibt, wenn »unique_lock« seinen Gültigkeitsbereich verlässt.

Die Klasse »std::unique_lock« kann deutlich mehr als ihr kleiner Bruder »std::lock_guard« . Neben dem expliziten Sperren und Freigeben des Mutex erlaubt es ein »unique_lock« , das Sperren des Mutex an absolute oder relative Zeitangaben zu knüpfen. Auch den Mutex testweise zu sperren unterstützt »unique_lock« . Die Details lassen sich unter [5] nachlesen.

Wie geht’s weiter?

Die Abstimmung per Zuruf mag in der menschlichen Arbeitswelt funktionieren, für Threads gibt es aber wesentlich zuverlässigere Mechanismen. Mit Bedingungsvariablen bietet C++11 verlässliche Werkzeuge an, die auf ihren Einsatz warten. Wie man sie verwendet, wird Thema des nächsten Artikels sein. (mhu)

Infos

  1. Rainer Grimm, “Mehrgleisig unterwegs”: Linux-Magazin 04/12, S. 96
  2. Thread als Daemon: http://en.cppreference.com/w/cpp/thread/thread/detach
  3. Mutexe und Locks: http://en.cppreference.com/w/cpp/thread
  4. Resource Acquisition Is Initialization: http://de.wikipedia.org/wiki/Ressourcenbelegung_ist_Initialisierung
  5. »unique_lock« : http://en.cppreference.com/w/cpp/thread/unique_lock

Der Autor

Rainer Grimm arbeitet seit 1999 als Software-Entwickler bei der Science + Computing AG in Tübingen. Insbesondere hält er Schulungen für das hauseigene Produkt SC Venus. Im Dezember 2011 ist sein Buch “C++11: Der Leitfaden für Programmierer zum neuen Standard” im Verlag Addison-Wesley 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
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben