Tasks in der Form von Promisen und Futuren in C++11 genießen einen ambivalenten Ruf. Zum einen sind sie deutlich leichter zu verwenden als Threads oder Bedingungsvariablen, zum anderen besitzen sie eine große Unzulänglichkeit. Sie können nicht komponiert werden. Mit dieser Unzulänglichkeit räumt C++20 auf.
Bevor ich auf die erweiterten Futures eingehen, noch ein paar Worte zu den Vorteilen von Tasks gegenüber Threads im Schnelldurchlauf.
Die Abstraktion von Tasks
Der entscheidende Vorteil von Tasks gegenüber Threads ist es, dass sich der Programmierer bei Tasks nur Gedanken darum machen muss, was berechnet wird und nicht wie bei Threads, wie es berechnet wird. Der Programmierer gibt dem System eine zu berechnende Aufgabe und das System sorgt dafür, dass diese Aufgabe möglichst intelligent von der C++ Laufzeit ausgeführt wird. Das kann bedeuten, dass die Aufgabe im gleichen Prozess ausgeführt oder in einem separaten Thread gestartet wird. Das kann aber auch bedeuten, dass sich ein Thread automatisch eine Aufgabe von einem anderen Thread schnappt, falls er gerade nichts zu tun hat. Unter der Decke wartet ein Threadpool, der die Aufgaben annimmt und intelligent verteilt. Wenn das keine Fortschritt ist.
Ich habe schon einige Artikel zu Tasks in Form von std::async, std::packaged_task und std::promise und std::future geschrieben. Die Details gibt es unter dem Tag Tasks. Nun aber zur Zukunft von Tasks in C++.
Der Name erweiterte Futures erklärt sich recht einfach. Zum einen wurde das Interface von std::future erweitert, zum anderen gibt es neue Funktionen, die besondere Futures erzeugen, die zur Komposition geeignet sind. Zuerst zu dem erweiterten std::future's.
Erweiterte Futures
std::future besitzt drei neue Methoden.
std::future
Die drei neuen Methoden im Überblick:
- Den unwrapping constructor, der einen in einem Future verpackten Future (future<future<T>>) vom äußeren Future befreit.
- Das Prädikat is_ready, das zurückgibt, ob der gemeinsame Zustand zur Verfügung steht.
- Die Methode then, die eine Continuation an ein Future anheftet.
Zuerst zu einer Spitzfindigkeit. Der Zustand eines Futures kann valid oder ready sein.
valid versus ready
- valid ist ein Future, wenn er einen gemeinsamen Zustand (mit einem Promise) hat. Das muss nicht sein, da std::future einen Default-Konstruktor besitzt.
- ready ist ein Future, wenn der gemeinsame Zustand zur Verfügung steht. Oder anders ausgedrückt, wenn der Promise seinen Wert produziert hat.
Damit ist (valid == true) Voraussetzung für (ready==true).
Wer wie ich Promise und Future als zwei Endpunkte ansieht, die durch einen Datenkanal verbunden sind, dem will ich mein mentales Bild von valid und ready nicht vorenthalten. Im Artikel Tasks ist ein Bild zu meinem mentalen Modell.
Der Future is valid, wenn er einen Datenkanal zu einem Promise besitzt. Der Future ist ready, wenn der Promise seinen Wert in den Datenkanal geschoben hat.
Nun aber zu der neuen Methode then.
Continuation mit then
then erlaubt es, einen Future an einen anderen Future anzuhängen. Hier tritt häufig das Phänomen auf, dass ein Future in einem Future verpackt wird. Dies löst die unwrapping constructor Methode automatisch.
Bevor ich den ersten Beispielcode zeige, eine Bemerkung zum Proposal n3721. Aus dem Proposal zu "Improvements for std::future<T> and Releated APIs" habe ich viel herausgezogen um diesen Artikel zu schreiben. Auch meine Beispiele stammen aus dem Proposal. Seltsamerweise fehlt in den Beispielen des Proposals oft die entscheidende get Abfrage des resultierenden Futures res um das Ergebnis der Futurekomposition zu erhalten. Daher habe ich in den Beispielen die res.get() Abfrage hinzugefügt und speichere das Ergebnis in der Variable myResult. Zusätzlich habe ich ein paar Typos gefixt.
1
2
3
4
5
6
7
8
9
10
11
12
|
#include <future>
using namespace std;
int main() {
future<int> f1 = async([]() { return 123; });
future<string> f2 = f1.then([](future<int> f) {
return to_string(f.get()); // here .get() won’t block
});
auto myResult= f1.get();
}
|
Es gibt einen feinen Unterschied zwischen dem f.get().to_string() - Aufruf (Zeile 7) und dem f1.get()-Aufruf in Zeile 10. Wie bereits in dem Code angedeutet, ist der erste Aufruf nicht blockierend oder auch asynchron, hingegen ist der zweite Aufruf blockierend oder auch synchron. Der f1.get() - Aufruf wartet, bis das Ergebnis der verketteten Futures zur Verfügung steht. Diese Aussage trifft nicht nur auf Future-Kompositionen wie f1.then(...).then(...).then(...).then(...) zu, sondern auf alle Kompositionen von erweiterten Futures zu. Der finale f1.get() Aufruf ist blockierend.
std::async, std::packaged_task und std::promise
Zu den Erweiterungen von std::async, std::package_task und std::promise gibt es nicht viel zu sagen. Nur, dass alle drei in C++20 erweiterte Futures zurückgeben. Richtig interessant ist die Komposition von Futures. Erlaubt sie doch Kompositionen von asynchronen Aufgaben.
Neue Futures erzeugen
C++20 erhält 4 weitere Funktionen, die das Erzeugen von besonderen Futures erlaubt. Diese sind std::make_ready_future, std::make_exceptional_future, std::when_all und std::when_any. Zuerst zu den beiden Funktionen std::make_ready_future und std::make_exceptional_future.
std::make_ready_future und std::make_exception_future
Beide Funktionen erzeugen einen Future, der sofort ready ist. Im ersten Fall besitzt der Future einen Wert, im zweiten Fall als Wert eine Ausnahme. Was sich am Anfang seltsam anhört, macht durchaus Sinn. Das Erzeugen eines ready Futures setzte mit C++11 einen Promise voraus. Dies ist selbst dann notwendig, wenn der Wert des gemeinsamen Zustands sofort zur Verfügung steht.
future<int> compute(int x) {
if (x < 0) return make_ready_future<int>(-1);
if (x == 0) return make_ready_future<int>(0);
future<int> f1 = async([]() { return do_work(x); });
return f1;
}
So muss in dem Beispiel das Ergebnis nur aufwändig mit Hilfe eines Promise berechnet werden, wenn (x > 0) gilt. Nur als Hinweis. Die beiden Funktionen sind das Pendant zur return Funktion in einer Monade. Im Artikel Monaden in C++ bin ich bereits auf diesen sehr interessanten Aspekt von erweiterten Futures in C++ eingegangen. In dem Artikel lag mein Fokus aber auf der funktionalen Programmierung mit C++20.
Nun geht es aber los mit der Future-Komposition.
std::when_all und std::when_any
Beide Funktionen habe einige Ähnlichkeiten.
Zuerst zur Eingabe. Beide Funktionen nehmen entweder ein Iteratorpaar oder eine beliebige Anzahl von Futures an. Der große Unterschied ist aber, dass im Fall der Iteratorpaare die Futures den gleichen Typ besitzen müssen, dass im Fall der beliebigen Anzahl von Futures, die Futures verschiedene Typen haben können und somit auch std::future und std::shared_future gleichzeitig verwendet werden können.
Die Ausgabe der Funktionen hängt davon ab, ob ein Iteratorpaar oder eine beliebige Anzahl von Futures (Variadic Template) verwendet wurde. Beide Funktionen geben einen Future fut zurück. Wird ein Iteratorpaar verwendet, ist der Rückgabetyp ein Future von Futures in einem Vektor verpackt: futur<vector<futureR>>>. Wird ein Variadic Template verwendet, ist der Rückgabetyp ein Future von Futures in einem Tuple: future<tuple<future<R0>, future<R1>, ...>> verpackt.
Das war es mit den Gemeinsamkeiten. Der Future, den die beiden Funktionen zurückgeben, ist dann ready, wenn alle Eingabe-Futures ready sind (when_all), oder wenn einer der Eingabe-Future ready ist (when_any).
Die zwei Beispiele sollen die Verwendung von when_all und when_any verdeutlichen.
when_all
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
#include <future>
using namespace std;
int main() {
shared_future<int> shared_future1 = async([] { return intResult(125); });
future<string> future2 = async([]() { return stringResult("hi"); });
future<tuple<shared_future<int>, future<string>>> all_f = when_all(shared_future1, future2);
future<int> result = all_f.then([(future<tuple<shared_future<int>,
future<string>>> f){ return doWork(f.get()); });
auto myResult= result.get();
}
|
Der Future all_f (Zeile 9) komponiert die beiden Future shared_future1 (Zeile 6) und future2 (Zeile 7). Future result in Zeile 11 wird dann ausgeführt, wenn beide Future (when_all) ready sind. In diesem Fall wird auf dem Future all_f get() in Zeile 12 ausgeführt. Das Ergebnis steht in dem Future result zur Verfügung und kann in Zeile 14 abgefragt werden.
when_any
Der resultierende Future in when_any kann mit einer einfachen Struktur when_any_result angenommen werden, die die Information enthält, welcher der Eingabe-Futures ready ist. Ansonsten müsste im Falle von when_any jeder Eingabe-Future abgefragt werden, ob er ready ist. Das ist natürlich umständlich.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
#include <future>
#include <vector>
using namespace std;
int main(){
vector<future<int>> v{ .... };
auto future_any = when_any(v.begin(), v.end());
when_any_result<vector<future<int>>> result= future_any.get();
future<int>& ready_future = result.futures[result.index];
auto myResult= ready_future.get();
}
|
future_any ist der Future, der ready ist, wenn einer seiner Future fertig ist. future_any.get() in Zeile 11 gibt der Future result zurück. Mittels der Abfrage result.futures[result.index] (Zeile 13) steht der ready Future zur Verfügung und das Ergebnis der Berechnung kann dank ready_future.get() abgefragt werden.
Wie geht's weiter?
Latches und Barriers erlauben es, dass mehrere Threads an einem Synchronisationspunkt warten, bis eine Bedingung erfüllt ist. Die neue Multithreading Konzept in C++20 werde ich mir im nächsten Artikel genauer anschauen.
Go to Leanpub/cpplibrary "What every professional C++ programmer should know about the C++ standard library". Hole dir dein E-Book. Unterstütze meinen Blog.
Weiterlesen...