std::future Erweiterungen

Inhaltsverzeichnis[Anzeigen]

Tasks in der Form von Promisen und Futuren in C++11 besitzen 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. Zu 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 sehr neuen Methode then.

Continuation mit then

then erlaubt es, einen Future an einen anderen Future anzuhängen. Hier tritt dann häufig das Phänomen auf, das 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 Futuren erlaubt. Diese sind std::make_ready_future, std::make_execptional_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 eine Ausnahme. Was sich am Anfang seltsam anhört, macht durchaus Sinn. Das Erzeugen eins 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 Futuren an. Der große Unterschied ist aber, dass im Fall der Iteratorpaare die Future den gleichen Typ besitzen müssen, dass im Fall der beliebigen Anzahl von Futuren die Future verschiedene Typen besitzen können und 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 Futuren (Variadic Template) verwendet wurde. Beide Funktionen geben eine Futur fut zurück. Wird ein Iteratorpaar verwendet, ist der Rückgabetyp ein Future von Futuren in einem Vektor verpackt: futur<vector<futureR>>>. Wird ein Variadic Template verwendet, ist der Rückgabetyp ein Future von Futuren in einem Tuple: future<tuple<future<R0>, future<R1>, ...>> verpackt.

Das war mit den Gemeinsamkeiten. Der Future, den die beiden Funktionen zurückgeben, ist dann ready, wenn alle Eingabe-Future 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, die die Information enthält, welcher der Eingabe-Future 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 den 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.

 

 

 

 

 

 

 

title page smalltitle page small 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.

Tags: Tasks, C++20

Kommentar schreiben


Abonniere den Newsletter (+ pdf Päckchen)

Beiträge-Archiv

Sourcecode

Neuste Kommentare