Jobs starten mit Coroutinen in C++20

Modernes C++ Rainer Grimm  –  177 Kommentare

C++20 besitzt drei neue Schlüsselwörter, um eine Funktion in eine Coroutine zu transformieren: co_return, co_yield und co_await. Letzteres benötigt ein Awaitable als Argument und startet den Awaiter-Arbeitsablauf. In diesem Artikel möchte ich auf die Awaitables genauer eingehen.

Den Inhalt dieses Artikels zu verstehen, setzt die Lektüre der vorherigen Artikel zu Coroutinen voraus, die sie aus der praktischen Perspektive beleuchten:

co_return

co_yield

Vor der Thematisierung der Awaitables und ihrer Anwendung gilt es zuerst, auf den Awaiter-Arbeitsablauf einzugehen.

Der Awaiter-Arbeitsablauf

Der Awaiter-Arbeitsablauf basiert auf den Methoden des Awaitables: await_ready(), await_suspend() und await_resume(). C++20 bietet zwei elementare Awaitables an: std::suspend_always und std::suspend_never. Beide habe ich in den vorherigen Artikeln bereits eingesetzt.

  • std::suspend_always
struct suspend_always {
constexpr bool await_ready() const noexcept { return false; }
constexpr void await_suspend(std::coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {}
};
  • std::suspend_never
struct suspend_never {
constexpr bool await_ready() const noexcept { return true; }
constexpr void await_suspend(std::coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {}
};

Dies ist der Awaiter-Arbeitsablauf "in Prosa":

awaitable.await_ready() returns false:                   // (1)

suspend coroutine

awaitable.await_suspend(coroutineHandle) returns: // (3)

void: // (4)
awaitable.await_suspend(coroutineHandle);
coroutine keeps suspended
return to caller

bool: // (5)
bool result = awaitable.await_suspend(coroutineHandle);
if result:
coroutine keep suspended
return to caller
else:
go to resumptionPoint

another coroutine handle: // (6)
auto anotherCoroutineHandle =
awaitable.await_suspend(coroutineHandle);
anotherCoroutineHandle.resume();
return to caller

resumptionPoint:

return awaitable.await_resume();

Der Arbeitsablauf wird nur dann ausgeführt, wenn awaitable.await_ready() false (Zeile 1) zurückgibt. Falls der Funktionsaufruf hingegen true ergibt, ist die Coroutine bereits fertig und gibt als Ergebnis den Wert von awaitable.await_resume() (Zeile 2) zurück.

Lass mich daher annehmen, dass false zurückgegeben wird. Dann wird die Ausführung der Coroutine zuerst pausiert (Zeile 3) und sofort das Ergebnis des Aufrufs awaitable.await_suspend() ausgewertet. Der Rückgabewert kann void (Zeile 4), ein Wahrheitswert (Zeile 5) oder ein anderes Coroutinen-Handle (Zeile 6) wie anotherCoroutineHandle sein. Abhängig vom Rückgabewert wird der Kontrollfluss mit dem Aufrufer oder eine andere Coroutine fortgesetzt.

Einen Job auf Anfrage starten

Die Coroutine im folgenden Beispiel ist so einfach wie möglich gehalten. Sie wartet mithilfe des vordefinierten Awaitables std::suspend_never():

// startJob.cpp

#include <coroutine>
#include <iostream>

struct Job {
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
handle_type coro;
Job(handle_type h): coro(h){}
~Job() {
if ( coro ) coro.destroy();
}
void start() {
coro.resume(); // (6)
}


struct promise_type {
auto get_return_object() {
return Job{handle_type::from_promise(*this)};
}
std::suspend_always initial_suspend() { // (4)
std::cout << " Preparing job" << '\n';
return {};
}
std::suspend_always final_suspend() noexcept { // (7)
std::cout << " Performing job" << '\n';
return {};
}
void return_void() {}
void unhandled_exception() {}

};
};

Job prepareJob() { // (1)
co_await std::suspend_never(); // (2)
}

int main() {

std::cout << "Before job" << '\n';

auto job = prepareJob(); // (3)
job.start(); // (5)

std::cout << "After job" << '\n';

}

Mancher mag vielleicht denken, dass die Coroutine prepareJob (Zeile 1) sinnfrei ist, da das Awaitable immer pausiert. Mitnichten, die Funktion prepareJob ist zumindest eine Coroutinen-Fabrik, die co_await (Zeile 2) verwendet und ein Coroutinen-Objekt zurückgibt. Die Funktion createJob() in Zeile 3 erzeugt ein Coroutinen-Objekt vom Typ Job. Bei der Analyse der Coroutine Job fällt auf, dass die Coroutine sofort pausiert wird, da die Methode initial_suspend des Promise den Awaitable std::suspend_always (Zeile 5) zurückgibt. Das ist genau der Grund, warum der Funktionsaufruf job.start (Zeile 5) notwendig ist, um die Coroutine aufzuwecken (Zeile 6). Die Methode final_suspend (Zeile 7) gibt ebenfalls std::suspend_always zurück.

Das Programm startJob.cpp ist ein idealer Startpunkt für weitere Experimente. Es hilft deutlich dem Verständnis, wenn sich der Arbeitsablauf direkt nachvollziehen lässt.

Der transparente Arbeitsablauf

Dem vorherigen Programm habe ich einige Kommentare hinzugefügt:

// startJobWithComments.cpp

#include <coroutine>
#include <iostream>

struct MySuspendAlways { // (1)
bool await_ready() const noexcept {
std::cout << " MySuspendAlways::await_ready" << '\n';
return false;
}
void await_suspend(std::coroutine_handle<>) const noexcept {
std::cout << " MySuspendAlways::await_suspend" << '\n';

}
void await_resume() const noexcept {
std::cout << " MySuspendAlways::await_resume" << '\n';
}
};

struct MySuspendNever { // (2)
bool await_ready() const noexcept {
std::cout << " MySuspendNever::await_ready" << '\n';
return true;
}
void await_suspend(std::coroutine_handle<>) const noexcept {
std::cout << " MySuspendNever::await_suspend" << '\n';

}
void await_resume() const noexcept {
std::cout << " MySuspendNever::await_resume" << '\n';
}
};

struct Job {
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
handle_type coro;
Job(handle_type h): coro(h){}
~Job() {
if ( coro ) coro.destroy();
}
void start() {
coro.resume();
}


struct promise_type {
auto get_return_object() {
return Job{handle_type::from_promise(*this)};
}
MySuspendAlways initial_suspend() { // (3)
std::cout << " Job prepared" << '\n';
return {};
}
MySuspendAlways final_suspend() noexcept { // (4)
std::cout << " Job finished" << '\n';
return {};
}
void return_void() {}
void unhandled_exception() {}

};
};

Job prepareJob() {
co_await MySuspendNever(); // (5)
}

int main() {

std::cout << "Before job" << '\n';

auto job = prepareJob(); // (6)
job.start(); // (7)

std::cout << "After job" << '\n';

}

Zuerst einmal habe ich die vordefinierten Awaitables std::suspend_always und std::suspend_never mit den Awaitables MySuspendAlways (Zeile 1) und MySuspendNever (Zeile 2) ersetzt. Diese kommen in den Zeilen 3, 4 und 5 zum Einsatz. Sie verhalten sich wie die vordefinierten Awaitables, schreiben aber zusätzlich eine kurze Nachricht. Wegen der Verwendung von std::cout lassen sich die Methoden await_ready, await_suspend und await_resume nicht als constexpr erklären.

Der Screenshot zur Programmausführung, der sich direkt auf dem Compiler Explorer nachvollziehen lässt, sollte Licht ins Dunkel bringen.

Die Funktion initial_suspend (Zeile 3) wird am Anfang der Coroutine und die Funktion final_suspend an ihrem Ende (Zeile 4) ausgeführt. Der Aufruf prepareJob() (Zeile 6) stößt das Erzeugen des Coroutinen-Objekts, die Funktion job.start() ihr Aufwecken und damit ihr vollständiges Ausführen (Zeile 7) an. Konsequenterweise werden den Methoden await_ready, await_suspend und await_resume von MySuspendAlways ausgeführt. Wenn du das Awaitable, das durch die Methode final_suspend zurückgegeben wird, nicht aufweckst, wird die Funktion await_resume nicht ausgeführt. Im Gegensatz dazu ist MySuspendNever sofort ausführbar, denn await_ready gibt true zurück. Damit wird MySuspendNever nicht pausiert.

Dank der Kommentare sollte der Awaiter-Arbeitsablauf nun vertraut sein. Jetzt ist es an der Zeit, diesen zu variieren.

Wie geht's weiter?

In meinem nächsten Artikel werde ich den Awaiter auf demselben und einem separaten Thread aufwecken.

Fünf Gutscheine für meinen Heise-Academy-Kurs

Für meinen Kurs "Der C++20-Kurs: Concepts, Ranges, Module und Coroutinen" gibt es fünf Gutscheine zu gewinnen. Mich interessiert vor allem die Antwort auf die Frage: Welche C++20-Feature soll ich als Nächstes angreifen? Ein Überblick zu den C++20-Featuren gibt das Inhaltsverzeichnis zu meinen Artikeln für heise Developer.

Anworten bis zum 11. April direkt an Rainer.Grimm@modernescpp.de.