Einführung

Liebe Leserinnen, liebe Leser,

in diesem Beitrag werde ich meinen letzten Beitrag über C++ Nebenläufigkeit fortfahren, in dem meist über Datenteilung und Threadverwaltung diskutiert wurde. Weil es außer der genannten Themen im Bereich Nebenläufigkeit auch andere gibt, lohnt es sich, einen zweiten Beitrag zu schreiben, der über Funktionen diskutiert, die das Warten auf Ereignisse, die Arbeiteinführung in einen vorhandenen Thread, und die Erstellung von locklosen synchronisierten Datenbrücken unterstützen. Bevor ich die Diskussion über diese Themen starten werde, möchte ich die Gegenerklärung des vorigenen Beitrags hier auch empfehlen. Los geht’s.

Bedingungsvariablen

Bedingungsvariablen wurden in die C++ Threading-Bibliothek eingeführt, um die teueren Arten von Warten, die während einige Ereignisse entstehen, zu vermeiden. Schauen wir uns die verschiedenen Arten von Warten an.

nameAbbildung 1: Thread ‘A’ fragt kontinuerlich f ab, um es herausfinden, ob es true ist.

Das ist die einfachste Art von Warten auf einen Zustand, der mithilfe einer einfachen Schleife implementiert werden kann. Thread ‘A’ fragt f kontinuerlich ab, bis es den Wert true annimmt, aber das ist eine ressourcenintensive Aufgabe. Deswegen ist das als busy-wait auch genennt.

nameFigure 2: Thread ‘A’ fragt f kontinurelich ab, aber manche Schlafzyklen sind zwischen die Assertionen eingefügt, um Computerressourcen freizumachen.

In diesem zweiten Fall schläft Thread ‘A’ zwischen den Assertionen, also Computerressourcen sind während der Warten freigemacht. Eine solche Lösung kann mithilfe einer Schleife und auf std::this_thread aufgerufene sleep_for() geschafft werden, aber das Problem ist, dass es sehr schwierig ist, die geeignete Zeitdauer für die Warten zu finden. Falls die Zeitdauer zu kurz ist, ist der Gewinn ganz klein, weil der Preis des regelmäßigen Schlafengehens wegen des Kontextwechsels hoch sein könnte. Andererseits, falls die Zeitdauer zu lang ist, könnte die Wahrscheinlichkeit für Zeitverschwendung sehr hoch sein, falls die Bedingungsvariable während des Wartens früher true wird. Eine ähnliche Situation ist auf dem Schaubild zu sehen: f wird mitterdrin des Schlafens true, also die Hälfte des Wartens ist vertrödelt. Wenn die Zeitdauer des Wartens für 200 ms angestellt würde, und f würde true nach nur 40 ms, dann würde Thread ‘A’ für 160 ms komplett unnötig warten. In einigen Fällen könnte das nicht so schlecht sein, aber im Falle von einem grafischen Programm oder einer schwer benutzten Datenbank würde das eine furchtbare Benutzererfahrung und Ineffizienz ergeben.

nameAbbildung 3: Thread ‘A’ schläft bis f wird true. Computerressourcen sind frei, und sie können mittlerweile von anderen Aufgaben benutzt werden.

Das oben zu sehende Ausführungsmuster scheint alle obengenannte Probleme zu beheben, weil Thread ‘A’ Ressourcen nur in den nötigsten Fällen verbraucht, und er wartet nur so viel wie erforderlich, und nicht mehr. Das Bild unten zeigt das im Einsatz:

nameAbbildung 4: Ein std::condition_variable im Einsatz. Es wartet auf die Erfüllung einer Bedingung.

Die wait() Funktion funktioniert auf die folgende Weise: wenn sie ein aufrufbares Objekt (in diesem Fall ein Lambda-Funktion) bekommt, wird das true zurückgeben (Bedingung wurde erfüllt). Weiterhin wird der Lock nicht freigestellt, und der geschützte Code wird ausgeführt. Falls die Funktion false zurückgibt, wird der Mutex entsperrt, und dann geht der Thread schlafen, bis ein anderer Thread notify_one() oder notify_all() aufruft. Wenn eine der notify Funktionen aufgerufen wird, wird der Thread aufwachen und den Mutex sperren, und letztendlich wird die Bedingung mal wieder überprüft. Theoretisch soll es so funktionieren, aber in der Wirklichkeit hängt die Schlafzeit von der Implementation ab.

Letztendlich, es gibt ein wichtiger Aspekt der obengenannten Konzepte: Die Bedingungen, die die Threads überwachen, können beliebig oft gültig werden oder zurückgesetzt werden. Infolgedessen gibt es immer (mindestens) einen Thread, der die Bedingungen gültig macht, und mittlerweile werden andere Threads an der Rücksetzung arbeiten. Alle diese zeigen eine sehr wichtige Eigenschaft der Bedingungsvariablen: sie enthalten keine Information über die Bedingung, stattdessen führen sie nach einer Anzeige nur die (durch den Konstruktor) angegebenen Tests aus. Deshalb sind diese Objekte statt eine zauberische boolesche Variable nur eine Art von Kommunikation.

nameAbbildung 5: Ein gewöhnlicher Arbeitsablauf, der Bedingungsvariablen verwendet.

Future und async

Als es schon in dem letzten Abschnitt darüber diskutiert wurde, können einige Bedingungen gültig werden, dann wieder zurückgesetzt werden. Aber was wäre, wenn etwas nicht nur ein einziges Mal stattfinden soll, sondern auch soll das irreversibel sein (das soll im Laufe der Operation nicht zurückgesetzt werden). Die gute Nachrichten sind, dass solche Ereignisse C++ durch Future unterstützt. Lasst uns sie unter die Lupe stellen.

Die C++ Nebenläufigkeitsbibliothek bietet nicht nur einen Future, sondern auch zwei an: std::future und std::shared_future, die dem std::unique_ptr und std::shared_ptr nachempfunden sind:

nameAbbildung 6: Ähnlichkeiten zwischen Future und Smart-Pointer.

Aber was sind sie eigentlich? Einfach gesagt sind Futures Platzhalterobjekten für Werte, die nur in der Zukunft vorhanden werden, daher der Name. Weil Futures Daten übertragen können (aber das ist nicht erforderlich), sind sie Templates, genauso wie Smart-Pointer. Die vorhandenen Futures ähneln sich den Smart-Pointern in einem anderen Fall auch, weil der Zugriff auf einen nicht shared Future nur aus einem einzigen Thread möglich ist, aber der Zugriff auf einen shared Future ist aus mehereren Threads auch möglich.

nameAbbildung 7: std::thread und std::async benutzen, um neue Threads zu starten.

Aus der Abbildung ist ersichtlich, dass ein std::async einen std::future für den ersuchenden Thread erstellt, indem die durch std::async ausgeführte Funktion das Ergebnis in einem anderen Thread berechnet. Man könnte behaupten, dass sich std::async dem std::thread ähnelt, und das ist wirklich so. Beide sind für die Auslagerung einer Operation in einen neuen Thread benutzt, aber der Hauptunterschied ist, dass die Rückgabe von Daten aus einer durch std::thread ausgeführten Funktion viel komplizierter ist, als mit einer durch std::future ausgeführten Funktion, weil es keine direkte Weise gibt, durch die die Daten aus einem std::thread behandelten neuen Thread zurückgegeben werden kann. Natürlich, man muss Daten irgendwie weitergeben, um Ergebnisse zurückzuerhalten, und das ist ganz möglich, wenn dem std::future und std::thread zusätliche Argumente gegeben wird. Diese Tatsache wurde in dem ersten Teil dieses Beitrags leider vermieden. Es gibt auch andere Unterschiede. Zum Beispiel bei std::async bekommt man einen std::future, der die Referenz für den neuen Thread enthält, aber bei std::thread enthält die Referenz das benannte Thread-Objekt selbst.

Wie es das Schaubild zeigt, können die Daten aus dem Future mithilfe der get() Klassenfunktion abgerufen werden. Das Bild zeigt das auch, dass falls die Berechnung des Ergebnisses noch nicht fertig ist, wenn die get() Funktion aufgerufen wird, wird der aufrufende Thread auf die Vorhandensein des Ergebnisses warten. Anders ausgedrückt wird das darauf warten, bis der Future bereit wird. Das ist auch bemerkenswert: die Ausführung eines neuen Thread, der für eine std::async eingegebene Aufgabe nötig wäre, hängt von der Implementation ab. Dieses Merkmal kann durch die Weitergebung eines std::launch Typs auch kontrolliert werden.

Eine Aufgabe für spätere Nutzung verstecken

Der obengenannte Mechanismus funktioniert ganz gut, wenn man sich darum nicht kümmern muss, in welchem Thread die Aufgabe ausgeführt wird. Wenn man eine Aufgabe in einem ausgewählten Thread ausführen will, braucht man eine Funktionalität, die eine Funktion verpacken und dann an den ausgewählten Thread weitergeben kann. Weiterhin soll diese Funktionalität später das Ergebnis durch einen Future zurückgeben können. Ein aufmerksamer Leser konnte in dem letzten Satz eine versteckte Information auch bemerken, die von dem ‘später das Ergebnis…zurückgeben’ Teil angedeutet ist: Eine verpackte Aufgabe braucht nicht sofort ausgeführt werden, und das kann genauso wie alle andere Variablen weitergegeben werden. Solche Möglichkeiten sind von std::packaged_task ermöglicht.

nameAbbildung 8: Eine Aufgabe wird an einen anderen Thread für spätere Ausführung weitergegeben.

Das oben angeführte Schaubild ähnelt sich dem Bild, das in dem letzten Absatz die Funktionalitäten von std::async und std::thread zeigt, aber es gibt einen großen Unterschied: im Gegensatz zu einer Aufgabe, die einem std::thread weitergegeben wurde, wird die Aufgabe hier in einen vorhandenen Thread überwiesen werden, und die Ausführung wird nicht zwangsweise sofort gestartet. Das Schaubild soll den Leser nicht verwirren, weil die Überweisung einer Aufgabe in einen vorhanden Thread nicht erforderlich ist, aber das ist auch ganz möglich, einen neuen Thread mithilfe die obengenannten Funktionalitäten zu starten.

Futures und Promises

Bisher wurden Futures an spezifischen Funktionen gebunden, um sie später in dem Programm mit Daten zu füllen. Weiterhin, der Programmierer musste zum Zeitpunkt der Erstellung des Futures das wissen, welche Funktion die Daten erzeugen wird. Also, um mehr Flexibilität zu bieten, hat die Thread-Bibliothek Funktionalitäten, die die Erstellung von Futures ohne solches Wissen ermöglichen. Das sind std::promise. Sinn und Zweck des std::promise / std::future Paars ist die Erstellung einer synchronisierten Datenbrücke zwischen Threads, bei der der std::promise eine einmalige Datensenke ist, und der std::future ein einfacher Datenauslass ist. Man könnte an dieser Sache denken, dass sie eine in zwei geteilte Variable ist, die in einem Thread nur ein lesbarer Teil (std::future) hat, und in dem anderen Thread nur ein schreibbarer Teil (std::promise) hat:

nameFigure 9: Ein std::future wird von einer am Anfang unbekannten Funktion mit Daten einfüllt.

Wenn das oben angeführte Bild mit dem Bild, das die innere Funktionsweise einer verpackten Aufgabe zeigt, verglichen wird, wird das offenkunding, dass man keine im Voraus bekannte funct1 braucht, um die Aufgabe nach einem anderen Thread (für die Füllung des Futures) schicken zu können. Stattdessen schickt man eine nur schreibbare Variable, ohne sich darum zu kümmern, wo die Variable gefüllt werden. Es gibt einen anderen wichtigen Unterschied zwischen std::promise und gewöhnliche Variablen: ein std::promise kann nur einmal geschrieben werden. Falls set_value() mehrmals aufgerufen wird, wird der Promise einen Exception Typ speichern, das auch eine interessante Besonderheit eines std::promises ist. Wenn die get() Funktion dieses Futures aufgerufen wird, wird der Exception befreit werden, und dann soll man diesen Exception auf dem normalen Weg behandeln. Das ist nicht der einzige Fall, in dem ein Exception gespeichert werden kann, weil wenn ein Promise nicht erfüllt wird (der Promise wird vor der Einstellung eines Wertes zerstört werden), oder die gespeicherte Funktion nie ausgeführt wird (es ist vor dem Aufruf zerstört), wird der gleiche Mechanismus verwendet werden. Die Besonderheit des Futures ist dennoch allgemeiner als diese Fälle, weil in einem Future mithilfe der set_exception() Funktion alle Arten von Exceptions für eine spätere Ausführung gespeichert werden können.

Weil über alle (derzeit unterstützte) Hauptfunktionalitäten der Thread-Bibliothek diskutiert wurde, ist das an der Zeit, diesen Beitrag zu beenden. Das bedeutet nicht, dass über Nebenläufigkeit mehr nicht gesagt werden könnte, aber weil ich mich mit diesem Bereich nur bekannt mache, werde ich die umfangreiche Diskussion für die Profis lassen.

Wie immer, vielen Dank fürs Lesen.