Lazy Futures mit Coroutinen in C++20

Modernes C++ Rainer Grimm  –  15 Kommentare

Beginnend mit der Coroutinen-basierten Implementierung eines einfaches Futures im letzten Artikel "C++20: Einfache Futures mit Coroutinen implementieren", möchte ich nun einen Schritt weiter gehen. In diesem Artikel steht die Analyse der einfachen Coroutine an. Dazu soll die Coroutine eine Bedarfsauswertung umsetzen.

Bevor ich eine Variation des Futures vorstelle, sollte man seinen einfachen Programmablauf verstehen. Daher setze ich die Kenntnis des vorherigen Artikels "C++20: Einfache Futures mit Coroutinen implementieren" voraus. Viele Kommentare im Programm sollen helfen, seinen Arbeitsablauf offenzulegen. Darüber hinaus befindet sich bei jedem Programm ein Link zum ausführbaren Programm auf einem Online-Compiler.

Der transparente Arbeitsablauf

// eagerFutureWithComments.cpp

#include <coroutine>
#include <iostream>
#include <memory>

template<typename T>
struct MyFuture {
std::shared_ptr<T> value
MyFuture(std::shared_ptr<T> p): value(p) { // (3)
std::cout << " MyFuture::MyFuture" << '\n';
}
~MyFuture() {
std::cout << " MyFuture::~MyFuture" << '\n';
}
T get() {
std::cout << " MyFuture::get" << '\n';
return *value;
}

struct promise_type { // (4)
std::shared_ptr<T> ptr = std::make_shared<T>(); // (11)
promise_type() {
std::cout << " promise_type::promise_type" << '\n';
}
~promise_type() {
std::cout << " promise_type::~promise_type" << '\n';
}
MyFuture<T> get_return_object() {
std::cout << " promise_type::get_return_object" << '\n';
return ptr;
}
void return_value(T v) {
std::cout << " promise_type::return_value" << '\n';
*ptr = v;
}
std::suspend_never initial_suspend() { // (6)
std::cout << " promise_type::initial_suspend" << '\n';
return {};
}
std::suspend_never final_suspend() noexcept { // (7)
std::cout << " promise_type::final_suspend" << '\n';
return {};
}
void unhandled_exception() {
std::exit(1);
}
}; // (5)
};

MyFuture<int> createFuture() { // (2)
std::cout << "createFuture" << '\n';
co_return 2021;
}

int main() {

std::cout << '\n';

auto fut = createFuture(); // (1)
auto res = fut.get(); // (8)
std::cout << "res: " << res << '\n';

std::cout << '\n';

} // (12)

Der Aururf createFuture (Zeile 1) stößt das Erzeugen einer Instanz vom Datentyp MyFuture (Zeile 2) an. Bevor der Konstruktoraufruf von MyFuture (Zeile 3) vollständig ausgeführt wird, ist der Promise bereits erzeugt, aufgeführt und zerstört worden (Zeilen 4 bis 5). Der Promise wendet in jedem Schritt seines Ablaufs die Awaitable std::suspend_never (Zeilen 6 und 7) an. Daher wird die Coroutine nicht pausiert. Um das Ergebnis für einen späteren fut.get()-Aufruf (Zeile 8) zu sichern, ist der Promise zu allokieren. Darüber hinaus sichert der std::shared_ptr zu (Zeilen 3 und 10), dass das Programm kein Speicherleck verursacht. Als lokale Variable endet der Gültigkeitsbereich von fut in der Zeile 12. Daher ruft die C++-Laufzeit seinen Destruktor auf.

Das Programm lässt sich direkt mit dem Compiler Explorer ausführen.

Die präsentierte Coroutine wird sofort im Thread des Aufrufers ausgeführt.

Nun werde ich die Coroutine "lazy" ausführen.

Ein Lazy Future

Ein Lazy Future wird nur ausgeführt, wenn nach seinem Wert gefragt wird. Mit ein paar kleinen Anpassungen lässt sich die vorherige Coroutine in einen Lazy Future transformieren:

// lazyFuture.cpp

#include <coroutine>
#include <iostream>
#include <memory>

template<typename T>
struct MyFuture {
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;

handle_type coro; // (5)

MyFuture(handle_type h): coro(h) {
std::cout << " MyFuture::MyFuture" << '\n';
}
~MyFuture() {
std::cout << " MyFuture::~MyFuture" << '\n';
if ( coro ) coro.destroy(); // (8)
}

T get() {
std::cout << " MyFuture::get" << '\n';
coro.resume(); // (6)
return coro.promise().result;
}

struct promise_type {
T result;
promise_type() {
std::cout << " promise_type::promise_type" << '\n';
}
~promise_type() {
std::cout << " promise_type::~promise_type" << '\n';
}
auto get_return_object() { // (3)
std::cout << " promise_type::get_return_object" << '\n';
return MyFuture{handle_type::from_promise(*this)};
}
void return_value(T v) {
std::cout << " promise_type::return_value" << '\n';
result = v;
}
std::suspend_always initial_suspend() { // (1)
std::cout << " promise_type::initial_suspend" << '\n';
return {};
}
std::suspend_always final_suspend() noexcept { // (2)
std::cout << " promise_type::final_suspend" << '\n';
return {};
}
void unhandled_exception() {
std::exit(1);
}
};
};

MyFuture<int> createFuture() {
std::cout << "createFuture" << '\n';
co_return 2021;
}

int main() {

std::cout << '\n';

auto fut = createFuture(); // (4)
auto res = fut.get(); // (7)
std::cout << "res: " << res << '\n';

std::cout << '\n';

}

Zuerst möchte ich mir den Promise anschauen. Er pausiert immer an seinem Beginn (Zeile 1) und seinem Ende (Zeile 2). Darüber hinaus erzeugt die Funktion get_return_object (Zeile 3) das Objekt, das an den Aufrufer der Coroutine createFuture (Zeile 4) zurückgegeben wird. Der Future MyFuture ist deutlich interessanter. Er besitzt ein Handle coro (Zeile 5) auf den Promise. MyFuture verwendet den Handle, um den Promise zu verwalten. Er weckt den Promise auf (Zeile 6), fragt ihn nach seinem Ergebnis (Zeile 7) und zerstört ihn letztlich (Zeile 8). Das Aufwecken der Coroutine ist notwendig, da sie nicht automatisch ausgeführt wird (Zeile 1). Wenn der Klient fut.get() (Zeile 7) aufruft, um das Ergebnis zu erhalten, wird die Ausführung der Coroutine fortgesetzt (Zeile 6).

Das Programm lässt sich direkt mit dem Compiler Explorer ausführen.

Was passiert, wenn der Klient am Ergebnis nicht interessiert ist und somit die Coroutine nicht aufweckt? Das lässt sich einfach ausprobieren:

int main() {

std::cout << '\n';

auto fut = createFuture();
// auto res = fut.get();
// std::cout << "res: " << res << '\n';

std::cout << '\n';

}

Wie vermutet, werden der Promise und somit die Funktionen return_value und final_suspend nicht ausgeführt.

Lebenszeitherausforderungen von Coroutinen

Ein der Herausforderungen beim Umgang von Coroutinen ist es, ihre Lebenszeit richtig zu verwalten. Im ersten Programm eagerFuture.cpp speichert die Coroutine ihr Ergebnis im std::shared_ptr. Dies ist notwendig, denn die Coroutine wird sofort ausgeführt.

Im Programm lazyFuture.cpp pausiert der Aufruf final_suspend (Zeile 2) immer: std::suspend_always final_suspend(). Konsequenterweise lebt der Promise länger als sein Klient, und ein std::shared_ptr ist nicht notwendig. Wenn die Funktion final_suspend aber std::suspend_never verwendet, ist das Verhalten des Programms undefiniert, denn der Klient lebt in diesem Fall länger als der Promise. Damit endet die Gültigkeit von result, bevor der Klient danach fragt.

Wie geht's weiter?

Mein letzter Schritt in der Variation des Futures fehlt noch. Im nächsten Artikel werde ich die Ausführung der Coroutine auf einem separaten Thread fortsetzen.

C++ Schulungen

Ich freue mich darauf, modernes C++ schulen zu dürfen. Dies sind meine offenen Schulungen im nächsten halben Jahr. Zum jetzigen Zeitpunkt gehe ich davon aus, dass alle Schulungen online stattfinden werden.

Mehr Informationen gibt es hier: www.ModernesCpp.de