Open Source im professionellen Einsatz
Linux-Magazin 06/2012

Modernes C++ in der Praxis – Folge 4

Gemeinsam ins Ziel

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.

1585

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 2: Dank join() erhält das Kind Laufzeit.

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 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.

Diesen Artikel als PDF kaufen

Express-Kauf als PDF

Umfang: 4 Heftseiten

Preis € 0,99
(inkl. 19% MwSt.)

Linux-Magazin kaufen

Einzelne Ausgabe
 
Abonnements
 
TABLET & SMARTPHONE APPS
Bald erhältlich
Get it on Google Play

Deutschland

Ähnliche Artikel

  • C++11

    Die neue Zeitbibliothek von C++11 erweist sich als elementarer Bestandteil der Threading-Schnittstelle: Sowohl Threads, Locks und Bedingungsvariablen als auch Futures haben ein Verständnis von Zeit. Dank ihrer Unterstützung kann ein Entwickler unterschiedliche Wartestrategien verfolgen.

  • Reichhaltiges Angebot

    C++0x bringt nicht nur Veränderungen in der Kernsprache. Die Standardbibliothek der C++-Neuausgabe hat Multithreading, asynchrone Funktionsaufrufe, reguläre Ausdrücke und vieles mehr im Angebot.

  • C++11

    Die C++11-Reihe beschäftigt sich weiter mit dem Synchronisieren von Threads. Diesmal setzt der Chef-Thread Bedingungsvariablen ein, um die Tätigkeit seiner Mitarbeiter-Threads zu koordinieren.

  • C++11

    2014 ist ein besonderes Jahr für C++. Drei Jahre nach C++11 erfährt der Sprachstandard mit C++14 den letzten Feinschliff. Neben generischen Lambda-Funktionen und der vereinfachten Ermittlung des Rückgabetyps kann C++14 vor allem mit einem Feature punkten: Reader-Writer-Locks.

  • C++11

    Der C++-Spezialist Rainer Grimm nimmt ein diffiziles Thema in Angriff: Das neue Memory-Modell von C++11 führt unter Umständen zu überraschendem Verhalten von Programmen. Für mehr Disziplin sorgen atomare Variablen und die so genannte Memory-Ordnung.

comments powered by Disqus

Ausgabe 10/2017

Digitale Ausgabe: Preis € 6,40
(inkl. 19% MwSt.)

Artikelserien und interessante Workshops aus dem Magazin können Sie hier als Bundle erwerben.