C++20: Einfache Futures mit Coroutinen implementieren
Anstelle von return verwendet eine Coroutine co_return, um ihren Wert zurĂĽckzugeben. In diesem Artikel werde ich eine einfache Coroutine implementieren, die co_return verwendet.
- Rainer Grimm
Anstelle von return
verwendet eine Coroutine co_return
, um ihren Wert zurĂĽckzugeben. In diesem Artikel werde ich eine einfache Coroutine implementieren, die co_return
verwendet.
Warum schreibe ich nochmals über Coroutinen in C++20, obwohl ich deren Theorie bereits in mehreren Artikeln zu Coroutinen vorgestellt habe? Das liegt an meinen Erfahrungen mit Coroutinen. C++20 bietet keine konkrete Coroutinen, sondern ein Framework für das Implementieren von Coroutinen an. Es besteht aus mehr als 20 Funktionen, die teilweise implementiert werden müssen oder können. Basierend auf diesen Funktionen erzeugt der Compiler zwei Arbeitsabläufe, die das Verhalten einer Coroutine definieren. Um es kurz zu machen: Coroutinen sind ein zweischneidiges Schwert. Einerseits sind sie sehr mächtig, andererseits sind sie sehr anspruchsvoll, was es schwer macht, sie zu verstehen. In meinem Buch "C++20: Get the Details" habe ich ihnen mehr als 80 Seiten gewidmet und dabei immer noch nicht alle Details erklärt.
Aus meiner Perspektive besteht der einfachste – und vielleicht einzige – Weg, Coroutinen zu verstehen, darin, einfache Coroutinen zu modifizieren und ihr Verhalten zu studieren. Dies ist genau die Strategie, die ich in den folgenden Artikeln verwende. Um ihren Arbeitsablauf offenzulegen, werde ich viele Kommentare einsetzen und nur so viel Theorie hinzufügen, wie für das Verständnis der Interna notwendig sind. Meine Erklärungen erheben gar nicht den Anspruch, vollständig zu sein, und sind nur als Startpunkt gedacht, um das Wissen zu Coroutinen zu vertiefen.
Ein kleiner Auffrischer
- Eine Funktion wird aufgerufen und wieder verlassen.
- Eine Coroutine wird aufgerufen, ihre Ausführung kann aber pausiert und wieder fortgesetzt werden. Eine pausierende Coroutine lässt sich darüber hinaus zerstören.
Mit den neuen Schlüsselwörtern co_await
und co_yield
unterstĂĽtzt C++20 zwei neue Konzepte, um Funktionen auszufĂĽhren.
Dank des Ausdrucks co_await expression
ist es möglich, die Ausführung des Ausdrucks expression
zu pausieren und wieder aufzunehmen. Wenn co_await expression
in einer Funktion func
verwendet wird, muss der Aufruf auto getResult = func()
nicht automatisch blockieren, wenn das Ergebnis des Funktionsaufrufs func()
noch nicht zur Verfügung steht. Ein ressourcenintensives Blockieren lässt sich durch ein ressourcenfreundliches Warten ersetzen.
Der co_yield
-Ausdruck erlaubt Generatoren das Implementieren. Generatoren geben jedes Mal einen neuen Wert zurĂĽck, wenn sie danach gefragt werden. Ein Generator ist ein Datenstrom, aus dem sich Werte herausnehmen lassen. Dieser Datenstrom kann unendlich sein. Damit sind wir mitten in der Bedarfsauswertung in C++.
Zusätzlich gibt eine Coroutine ihr Ergebnis nicht mit return
, sondern mit co_return
zurĂĽck:
// ...
MyFuture<int> createFuture() {
co_return 2021;
}
int main() {
auto fut = createFuture();
std::cout << "fut.get(): " << fut.get() << '\n';
}
In diesem einfachen Beispiel ist createFuture
eine Coroutine, da sie eines der drei SchlĂĽsselworte co_return
, co_yield
oder co_await
verwendet. DarĂĽber hinaus gibt die Funktion createFuture
eine Coroutine MyFuture<int
>
zurĂĽck. Das hat mich oft verwirrt. Der Name Coroutine wird fĂĽr zwei Einheiten verwendet. Daher will ich zwei Begriffe einfĂĽhren. createFuture
ist eine Coroutinen-Fabrik, die ein Coroutinen-Objekt fut
zurĂĽckgibt, das eingesetzt werden kann, um nach dem Ergebnis zu fragen: fut.get()
.
Nun schlieĂźe ich die Theorie vorerst ab und gehe auf co_return
genauer ein.
co_return
Zugegeben, die Coroutine im folgenden Programm eagerFuture.cpp
ist die einfachste Coroutine, die ich mir vorstellen kann, die einen Mehrwert liefert: Sie speichert das Ergebnis ihres Aufrufs:
// eagerFuture.cpp
#include <coroutine>
#include <iostream>
#include <memory>
template<typename T>
struct MyFuture {
std::shared_ptr<T> value; // (3)
MyFuture(std::shared_ptr<T> p): value(p) {}
~MyFuture() { }
T get() { // (10)
return *value;
}
struct promise_type {
std::shared_ptr<T> ptr = std::make_shared<T>(); // (4)
~promise_type() { }
MyFuture<T> get_return_object() { // (7)
return ptr;
}
void return_value(T v) { // (8)
*ptr = v;
}
std::suspend_never initial_suspend() { // (5)
return {};
}
std::suspend_never final_suspend() { // (6)
return {};
}
void unhandled_exception() {
std::exit(1);
}
};
};
MyFuture<int> createFuture() { // (1)
co_return 2021; // (9)
}
int main() {
std::cout << '\n';
auto fut = createFuture();
std::cout << "fut.get(): " << fut.get() << '\n'; // (2)
std::cout << '\n';
}
MyFuture
verhält sich wie ein Future, der sofort ausgeführt wird (siehe "Asynchrone Funktionsaufrufe"). Der Aufruf der Coroutine createFuture
(Zeile 1) gibt den Future zurĂĽck, sodass der Aufruf fut.get()
(Zeile 2) das Ergebnis vom assoziierten Promise anfordern kann.
Es gibt einen kleinen Unterschied zu einem Future: Der RĂĽckgabewert der Coroutine createFuture
ist sofort nach ihrem Aufruf verfĂĽgbar. Wegen der Lebenszeit und den Anforderungen der Coroutinen werden diese von einem std::shared_ptr
(Zeile 3 und 4) gemanagt. Die Coroutinen verwenden immer std::suspend_never
(Zeile 5 und 6) und pausieren damit weder vor noch nach ihrer AusfĂĽhrung. Das heiĂźt, dass die Coroutine sofort ausgefĂĽhrt wird, wenn die Funktion createFuture
()
aufgerufen wird. Die Methode get_return_object
(Zeile 7) gibt den Handle auf die Coroutine zurĂĽck und speichert diesen in einer lokalen Variablen. return_value
(Zeile 8) speichert das Ergebnis der Coroutine, das durch co_return 2021
(Zeile 9) erzeugt wird. Der Klient ruft fut.get()
(Zeile 2) auf und verwendet den Future als Handle auf den Promise. Die Methode get()
liefert zum Abschluss das Ergebnis an den Client (Zeile 10).
Ist der Aufwand gerechtfertigt, eine Coroutine zu verwenden, wenn sich diese wie eine gewöhnliche Funktion verhält? Dem kann ich nichts erwidern. Jedoch ist diese Coroutine ein idealer Startpunkt für weitere Implementierung von Coroutinen.
Jetzt ist es Zeit fĂĽr ein wenig Theorie.
Der Promise-Workflow
Wenn co_yield
, co_await
oder co_return
in einer Funktion zum Einsatz kommen, wird diese Funktion zur Coroutine und der Compiler transformiert ihren Funktionskörper zu folgendem äquivalenten Code:
{
Promise prom; // (1)
co_await prom.initial_suspend(); // (2)
try {
<function body> // (3)
}
catch (...) {
prom.unhandled_exception();
}
FinalSuspend:
co_await prom.final_suspend(); // (4)
}
Wirken die Funktionsnamen vielleicht vertraut? Dies sind die Methoden der inneren Klasse promise_type
. Hier sind die Schritte, die der Compiler ausfĂĽhrt, wenn er das Coroutinen-Objekt als RĂĽckgabewert der Coroutinen-Fabrik createFuture
vollzieht. Zuerst erzeugt er das Promise-Objekt (Zeile 1), ruft dann die Funktion inital_suspend
(Zeile 2) auf, führt den Funktionskörper (Zeile 3) aus und ruft zum Abschluss die Methode final_suspend
(Zeile 4). Beide Methoden inital_suspend
und final_suspend
des Programms eagerFuture.cpp
geben das vordefinierte Awaitable std::suspend_never
zurück. Wie es der Name verspricht, pausiert dieses Awaitable nie und damit pausiert auch die Coroutine nie und verhält sich wie eine gewöhnliche Funktion. Ein Awaitable ist eine Einheit, auf die sich warten lässt. Genau das benötigt co_await
als Argument. Ich werde in zukĂĽnftigen Artikeln noch genauer auf Awaitables und den zweiten Awaiter-Workflow eingehen.
Aus dem vereinfachten Arbeitsablauf lässt sich einfach schließen, welche Methoden der Promise (promise_type
) mindestens benötigt:
- Default-Konstruktor
initial_suspend
final_suspend
unhandled_exception
Zugegeben, dies war nicht die vollständige Erklärung. Die Erklärung sollte aber eine erste Intuition zum Ablauf von Coroutinen vermitteln.
Wie geht's weiter?
Nun ist es wohl ersichtlich, womit sich mein nächster Artikel befasst. Zuerst dekoriere ich die Coroutine mit Kommentaren, damit sich ihr Arbeitsablauf transparent darstellen lässt, dann werde ich die Coroutine lazy implementieren und auf einem anderen Thread wieder starten.