Kapitel 4. Multithreading

In diesem Kapitel:

Mehrkernprozessoren sind der Standard, wenn es um den Arbeitsplatzrechner, den heimischen PC oder den Laptop geht. Daher ist es von existenzieller Bedeutung für eine moderne Programmiersprache, auf die Anforderungen der modernen Rechnerarchitekturen adäquate Antworten zu geben – zumal funktionale Programmiersprachen wie Clojure (Clojure, 2011) oder auch Haskell (The Haskell Programming Language, 2011) die Messlatte bei der Unterstützung von Nebenläufigkeit sehr hoch gelegt haben. Sowohl Clojure als auch Haskell bieten Software Transactional Memory (STM) an.

Diese Abstraktion der Multithreading-Unterstützung erreicht C++11 noch nicht, aber es befindet sich auf dem richtigen Weg. Die neuen Features sind:

Die Darstellung des Speichermodells und der atomaren Datentypen wird erst in Teil III Thema sein, da diese neuen Features deutlich das Niveau einer ersten Tour durch C++11 überschreiten.

Threads

Der Header <thread> inkludiert, und die neue Funktion std::thread steht zur Verfügung, um einen Thread zu erzeugen und sofort zu starten.

Ein Thread std::thread benötigt die Funktionalität, die in ihm ausgeführt werden soll. Dazu bieten sich drei Möglichkeiten an:

Das Programm in Listing 4.1 stellt die drei Möglichkeiten dar.

createThread.cpp

Sowohl der erste Thread t1 als auch der zweite Thread t2 in Listing 4.1 sollten relativ vertraut wirken. Anders verhält es sich mit dem letzten Thread t3 (Zeile 28), dessen Funktionalität direkt in der Lambda-Funktion angegeben ist. Da in diesem konkreten Fall die Lambda-Funktion keine Argumente erwartet, ist es nicht notwendig, die Klammerpaare für die Argumente anzugeben. Die Lambda-Funktion [](){ ... ;} lässt sich daher auf []{ ... ;} verkürzen. Threads, die nur ein paar Anweisungen ausführen müssen, sind ideale Kandidaten für Lambda-Funktionen, denn sie bieten entscheidende Vorteile:

  • Die Codefunktionalität wird direkt dort definiert, wo sie benötigt wird.

  • Keine unnötigen Funktionen oder Funktionsobjekte werden erzeugt.

join

Eine Funktion fehlt noch in der Erläuterung. In den Zeilen 31 bis 33 wird auf jedem Thread join aufgerufen. Dies bewirkt, dass der Vater-Thread auf die Beendigung der drei Threads wartet, sodass diese ihre Aufgabe vollständig ausführen können, bevor der Vater-Thread sich beendet.

detach

Durch detach wird das Gegenteil erreicht, denn diese Methode löst die Lebenszeit des neuen Threads vom Vater-Thread.

Das Ergebnis der Programmausführung ist, wie erwartet, nicht deterministisch.

Zwei Dinge fallen auf:

Die korrekte Programmausführung setzt voraus, dass std::cout nur exklusiv von einem Thread verwendet werden kann. Die naheliegende Lösung ist Locking (dazu bald mehr im Abschnitt „Schutz der Daten“).

Argumentübergabe

Die Threads waren sehr einfach strukturiert. Nun sollen sie Argumente erhalten. Als Grundlage dient das Programm in Listing 4.1.

createThreadWithArguments.cpp

Die Ausgabe des Programmlaufs entspricht im Wesentlichen der von Abbildung 4.1. Das nicht deterministische Verhalten besteht weiter darin, welcher Thread als Erster zum Zuge kommt und ob sich die Ausgaben auf die Konsole überschneiden. Interessanter ist da schon die Übergabe der Parameter an die Funktion (Zeile 22), an das Funktionsobjekt (Zeile 26) und vor allem an die Lambda-Funktion (Zeile 29).

Gemeinsam von Threads genutzte Daten wie std::cout müssen geschützt werden. Dafür gibt es in C++11 Mutexe und Locks.

Schutz der Daten

Mutex

Mutex steht für den englischen Ausdruck mutual exclusion. Durch wechselseitigen Ausschluss stellt der Mutex sicher, dass nur ein Thread Zugriff auf einen gemeinsam genutzten kritischen Bereich besitzt. Dieser kritische Bereich kann aus einem Variablenzugriff oder auch aus mehreren Anweisungen bestehen, die es zu schützen gilt.

Will ein Thread in den kritischen Bereich eintreten, muss er den Mutex locken. Dies ist aber nur möglich, wenn dieser nicht gelockt ist. Erhält der Thread den Lock nicht, wird er geblockt.

mutex

Der Gebrauch ist denkbar einfach.

std::mutex m;
// ...
m.lock();
//critical region
m.unlock();

Trotz dieser einfachen Nutzung sollte ein Mutex nicht direkt verwendet werden, denn er ist nur ein einfaches Werkzeug. Da ein Mutex in der Regel an mehreren Stellen im Sourcecode verwendet wird, ist die Gefahr sehr groß, dass er nicht mehr freigegeben wird. Das kann durch eine Nachlässigkeit oder durch eine Ausnahme passieren. Das Ergebnis ist das gleiche. Der Thread erhält den Mutex nicht mehr und bleibt geblockt.

Lock

Aus diesem Grund werden Mutexe in C++11 in Locks gepackt. Diese funktionieren nach dem bekannten C++-RAII-Idiom. RAII steht dabei für Resource Acquisition Is Initialization. Wie das RAII-Idiom funktioniert, wird im Anhang C, erläutert.

lock_guard

std::lock_guard und std::unique_lock sind das Mittel der Wahl in C++11, wenn es darum geht, den Zugriff auf einen kritischen Bereich durch Threads zu synchronisieren. Beide halten eine Referenz auf einen Mutex. Dabei ist std::lock_guard für den einfachen Einsatz ausgelegt, denn es bindet den Mutex in seinem Konstruktor und gibt ihn im Destruktor wieder frei, gemäß RAII-Idiom. Damit lässt sich die Race Condition aus Listing 4.1, in dem die Threads unkoordiniert auf die Konsole schreiben, einfach lösen.

lockStdout.cpp

Listing 4.5 wartet mit ein paar Neuheiten auf. So wird in Zeile 6 der coutMutex angelegt, der durch std::lock_guard sowohl von der Funktion (Zeile 11) als auch vom Funktionsobjekt (Zeile 22) und von der Lambda-Funktion (Zeile 41) verwendet wird. Diese Lambda-Funktion ist deutlich anspruchsvoller als alle bisher verwendeten anonymen Funktionen:

Die Freigabe des Mutex geschieht automatisch, sodass die drei Aufrufe von std::lock_guard für das koordinierte Schreiben nach std::cout sorgen.

unique_lock

Der std::lock_guard besitzt aber nur eine sehr eingeschränkte Funktionalität. Reicht dieses einfache Interface nicht aus, sollte der std::unique_lock verwendet werden. Vereinfacht gesagt, besitzt dieser nicht mehr die strenge 1:1-Beziehung zu seinem Mutex wie std::lock_guard. Dieser Aufbruch der engen Assoziation zwischen dem Mutex und seinem Lock besitzt mächtige Auswirkungen auf den std::unique_guard. So lassen sich mit ihm Deadlocks elegant verhindern oder zeitliche Bedingungen mit Locks verknüpfen. Die genaueren Details folgen in Teil III.

Oft ist es nicht nötig, eine Variable während ihres gesamten Lebenszyklus zu schützen, stattdessen muss nur ihre geschützte Initialisierung sichergestellt werden.

Sichere Initialisierung der Daten

Die einfachste Art, Daten geschützt zu initialisieren, sollte nicht vergessen werden, bevor die neuen C++11-Techniken folgen. Das Programm startet im Main-Thread. Daten, die in diesem initialisiert werden, solange noch kein Kind-Thread instanziiert wurde, werden zwangsläufig geschützt initialisiert.

C++11 kennt drei Arten, Variablen geschützt zu initialisieren. Dies sind:

In Listing 4.6 sind alle drei Variationen der Initialisierung von Daten dargestellt.

threadingInitialization.cpp

Durch constexpr (Zeile 8) wird der Standardkonstruktoraufruf (Zeile 34) zur Übersetzungszeit ausgeführt. myClass (Zeile 16) ist eine statische Variable mit Block-Gültigkeit. In diesem Fall stellt der C++11-Compiler sicher, dass die Funktion nur einmal und atomar ausgeführt wird. Aber auch zur Laufzeit lässt sich eine Variable geschützt initialisieren. Die Funktion createInstance (Zeile 22) initialisiert mithilfe des Flags initFlag die Variable myClass3 (Zeile 24) genau einmal.

Schutz von Daten ist aber nur notwendig, wenn diese von den Threads gemeinsam genutzt werden. Thread-lokale Daten verlangen keinen Schutz.

Durch das Schlüsselwort thread_local wird eine Thread-lokale Variable definiert. Jeder Thread besitzt eine Kopie der Variablen, die an die Lebenszeit des Threads gebunden ist.

Oft reicht es aber nicht aus, dass Threads koordiniert werden, stattdessen ist es notwendig, dass sie synchronisiert auf gemeinsam genutzten Daten arbeiten. Ein Thread kann mit seiner Arbeit erst beginnen, wenn ihm ein anderer Thread das entsprechende Signal sendet.

Für die Synchronisation von Threads sollen zwei Anforderungen erfüllt sein:

Mit dem Lock std::unique_lock, der die zu bearbeitenden Daten schützt, der Methode std::this_thread::sleep_for, die einen Thread für eine angegebene Zeit schlafen legt, und der neuen Zeitmethode std::chrono::milliseconds stehen alle Bausteine bereit, um einen Thread zu implementieren, der durch einen anderen Thread aufgeweckt wird. Ein einfacher Wahrheitswert dient zur Synchronisation der Threads in Listing 4.7.

Über den Wahrheitswert dataReady signalisiert der Sender, dass die Daten bereit sind. Bevor der Arbeiter den Wahrheitswert prüft und gegebenenfalls seine Arbeit in doTheWork (Zeile 16) aufnimmt, setzt er den Lock mit std::unique_lock (Zeile 6). Sind die Daten nicht bereit, löst er den Lock, legt sich für 50 Millisekunden schlafen und setzt den Lock wieder, um dataReady (Zeile 8) zu testen.

Die Funktion waitingForWork (Zeile 4) erfüllt die zwei Anforderungen aber nicht optimal. Zwischen dem Senden des Signals und dem Zeitpunkt, an dem der Worker seine Arbeit aufnimmt, vergehen im Mittel 25 ms (50 ms geteilt durch 2). Zwar lässt sich die Schlafphase einfach verkürzen, indem die Konstante verkleinert wird, dies geht aber auf Kosten der CPU, denn das Sperren und Entsperren des Lock benötigt CPU-Ressourcen.

Beide Bedingungen – kurzes Warten und geringe CPU-Auslastung – lassen sich mit den neuen Bedingungsvariablen in C++11 einfach erfüllen (Listing 4.8):

conditionVariable.cpp

Thread t1 verwendet die Funktion setDataReady (Zeile 27), um dem Thread t2 zu signalisieren, dass die Daten bereit sind. Durch condVar.notify_one (Zeile 33) weckt er den Worker auf. condVar.wait(lck,[]{return dataReady;} (Zeile 20) sperrt den Lock, prüft mit der Lambda-Funktion, ob die Bedingung erfüllt ist, und arbeitet doTheWork (Zeile 21) ab.

Die Programmausgabe zeigt die Interaktion von Arbeiter und Sender.

Neben notify_one kennt die Bedingungsvariable auch die Methode notify_all. Damit werden alle Threads, die gerade im Zustand wait sind, aufgeweckt.

Ob es die Basiswerkzeuge zum Erzeugen von Threads, zum Koordinieren von Threads wie Lock, zum Synchronisieren von Threads wie Bedingungsvariablen, Thread-lokale Daten oder auch atomare Datentypen waren – dies sind die einfachen Grundwerkzeuge, die jede Threading-Bibliothek mitbringen muss. Komfortabler wird der Umgang mit Threads aber erst, wenn nur die reine Funktionalität spezifiziert werden muss, die im Thread ausgeführt werden soll. Alle anderen Aspekte rund um das Thread-Handling werden vom System abgenommen. Letztendlich will der Anwender nur das Ergebnis der Tasks abfragen.

Genau diese High-Level-API bietet C++11 mit den asynchronen Tasks.

Die asynchrone Funktionalität kam relativ spät in den neuen C++11-Standard. Eine asynchrone Aufgabe besteht aus zwei Komponenten:

Promise

Future

Das Programm in Listing 4.5 illustriert, wie viel Tipparbeit investiert werden muss, um drei Threads zu erzeugen, die Ausgaben der Threads nach std::cout zu koordinieren und letztendlich mittels join zu gewährleisten, dass die Threads ihre Aufgabe vollenden können. Da ein Thread keinen Wert zurückgeben kann, wurde std::cout als Ergebniskanal missbraucht. Soll das Programm darüber hinaus die Ergebnisse der Threads in einer definierten Reihenfolge schreiben, müssten wir noch Bedingungsvariablen anwenden. Damit wäre das Programm vollkommen serialisiert und vom Programmablauf einem Single-Threaded-Programm sehr ähnlich.

Ganz schön viel Aufwand. Das geht deutlich einfacher mit std::async zum Starten einer asynchronen Aufgabe (Listing 4.9).

asyncStdout.cpp

Der Aufruf std::async lässt sich sowohl über eine Funktion (Zeile 26) als auch über ein Funktionsobjekt (Zeile 29) und eine Lambda-Funktion (Zeile 33) parametrisieren. Der Rückgabewert des Aufrufs, der vom expliziten Typ std::future<std::string> ist, wird durch das Schlüsselwort auto an die entsprechende Variable gebunden. Mit dem Future lässt sich das Ergebnis des asynchronen Tasks durch den get-Aufruf (Zeile 35) abholen. Der get-Aufruf eines Future ist blockierend.

Die Ausgabe ist mittlerweile vertraut.

Noch ein paar Worte zu Futures und Promises, die Details folgen in Kapitel 18.

Future

Im Gegensatz zu std::future aus Listing 4.9 bietet std::shared_future an, dass das Ergebnis mehrmals angefordert werden kann.

Promise

Neben dem automatischen Starten eines Tasks mit std::async ist dies in C++11 auch explizit mit der Funktion std::thread möglich. Dazu wird in Listing 4.10 ein Promise definiert. Über die get_future-Methode des Promise wird ein Future erzeugt und mit dem Promise verbunden. Der neue Thread erhält die Funktion asyncFunc und als Parameter den transferierten Promise: std::move(int Promise). Das Ergebnis wird in gewohnter Weise durch den get-Aufruf des Future eingefordert.

Die einzige Unbekannte in Listing 4.10 ist nur noch die Funktion asyncFunc.

asyncFunc erhält als Argument den Promise. Über seine Methode set_value oder gegebenenfalls set_exception steht der Rückgabewert für den Future zu Verfügung.

Damit verlassen wir das Feld der neuen Multithreading-Funktionalität in C++11 und kommen zu all den Erweiterungen, die die Standardbibliothek mit sich bringt.