C++20: Einfache Futures mit Coroutinen implementieren

Modernes C++ Rainer Grimm  –  26 Kommentare

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.