Softwareentwicklung: Ein kompakte EinfĂĽhrung in Coroutinen von Dian-Lun Li
Der Ausgangspunkt einer Miniserie zu einem Scheduler zum Verteilen von Tasks ist ein Gastbeitrag von Dian-Lun Li mit einer einfachen Umsetzung.
- Rainer Grimm
Heute beginne ich in meinem Blog eine Miniserie zu einem Scheduler von Tasks. Der Ausgangspunkt dieser Miniserie ist ein einfacher Scheduler von Dian-Lun Li, der immer ausgefeilter wird.
Ich habe bereits etwa 15 Artikel über Coroutinen geschrieben. Sie erklären die Theorie der Coroutinen und wenden sie auf verschiedene Weise an. Ich kämpfe aber immer noch um eine intuitive Einführung in einen nicht trivialen Anwendungsfall von Coroutinen. Deshalb war ich sehr froh, als ich den Vortrag von Dian-Lun Li auf der CppCon 2022 hörte: "An Introduction to C++ Coroutines through a Thread Scheduling Demonstration".
Heute freue ich mich, einen Gastartikel von Dian-Lun Li vorstellen zu können. Er wird Coroutinen intuitiv einführen, um einen einfachen Scheduler zu implementieren, der Tasks verteilt. Ich werde diesen Scheduler als Ausgangspunkt für weitere Experimente verwenden.
Eine EinfĂĽhrung in die C++-Coroutinen
Eine Coroutine ist eine Funktion, die sich selbst unterbrechen und vom Aufrufer wieder aufgenommen werden kann. Im Gegensatz zu normalen Funktionen, die sequenziell von Anfang bis Ende ausgeführt werden, können Coroutinen die Ausführung kontrolliert unterbrechen und wieder aufnehmen. So können wir Code schreiben, der synchron aussieht, aber asynchrone Vorgänge effizient abwickeln kann, ohne den aufrufenden Thread zu blockieren. Die Implementierung einer C++-Coroutine kann aufgrund ihrer Vielseitigkeit eine kleine Herausforderung sein. In C++-Coroutinen lässt sich das Verhalten einer Coroutine auf zahlreiche Arten fein abstimmen. Man kann zum Beispiel entscheiden, ob eine Coroutine beim Start oder beim Beenden unterbrochen werden soll. Man kann aber auch genau festlegen, wann und wo diese Unterbrechungen innerhalb der Coroutine stattfinden. Zur Veranschaulichung will ich mit einem einfachen Beispiel beginnen:
// simpleCoroutine.cpp
#include <coroutine>
#include <iostream>
struct MyCoroutine { // (1)
struct promise_type {
MyCoroutine get_return_object() {
return std::coroutine_handle<promise_type>::from_promise(*this);
}
std::suspend_always initial_suspend() {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
void return_void() {}
void unhandled_exception() {}
};
MyCoroutine(std::coroutine_handle<promise_type> handle): handle{handle} {}
void resume() {
handle.resume();
}
void destroy() {
handle.destroy();
}
std::coroutine_handle<promise_type> handle;
};
MyCoroutine simpleCoroutine() { // (2)
std::cout << "Start coroutine\n";
co_await std::suspend_always{};
std::cout << "Resume coroutine\n";
}
int main() {
MyCoroutine coro = simpleCoroutine();
std::cout << "Coroutine is not executed yet\n";
coro.resume();
std::cout << "Suspend coroutine\n";
coro.resume();
coro.destroy();
return 0;
}
Dieser Beispielcode demonstriert die grundlegende Verwendung von C++-Coroutinen. Zum Implementieren muss man vier wesentliche Komponenten verstehen: Coroutine, Promise-Typ, Awaitable und Coroutine-Handle. In den folgenden Abschnitten werde ich jede Komponente anhand des Beispielcodes erklären.
Coroutine
In C++ werden Coroutinen durch die Schlüsselwörter co_return
, co_await
und co_yield
implementiert. Diese Schlüsselwörter ermöglichen es Entwicklern, asynchrones Verhalten auf strukturierte und intuitive Weise auszudrücken. In der Beispiel-Coroutine simpleCoroutine
rufe ich co_await std::suspend always{}
auf, um die Coroutine anzuhalten. std::suspend_always
ist ein vom C++-Standard bereitgestelltes Awaitable, das die Coroutine immer suspendiert.
Beim Aufruf der Funktion simpleCoroutine
, wird die Coroutine nicht sofort ausgeführt. Stattdessen erhält man ein Coroutine-Objekt zurück, das den Promise-Typ definiert. (2) definiert die Funktion simpleCoroutine
, die ein MyCoroutine
-Objekt zurĂĽckgibt. In (1) definiere ich die Klasse MyCoroutine
und den Promise-Typ. Dass der Aufruf einer Coroutine-Funktion diese nicht sofort ausfĂĽhrt, liegt daran, dass die C++-Coroutine flexibel sein soll. Mit C++-Coroutine kann man entscheiden, wann und wie eine Coroutine beginnen und enden soll. Dies ist im promise_type
definiert.
Promise-Typ
Ein promise_type
steuert das Verhalten einer Coroutine. Hier sind die wichtigsten Aufgaben eines promise_type
:
- Erstellen des Coroutine-Objekts: Die Funktion
get_return_object
erstellt eine Instanz der Coroutine und gibt sie an den Aufrufer zurĂĽck. - Kontrolle der Suspension: Die Funktionen
initial_suspend
undfinal_suspend
bestimmen, ob die Coroutine am Anfang und am Ende unterbrochen oder fortgesetzt werden soll. Sie geben Awaitables zurück, die bestimmen, wie sich die Coroutine verhält. - Umgang mit Rückgabewerten: Die Funktion
return_value
legt den Rückgabewert der Coroutine fest, wenn sie abgeschlossen ist. Sie ermöglicht es der Coroutine, ein Ergebnis zu liefern, das der Aufrufer abrufen kann. Im Beispielcode verwende ichreturn_void
, um anzuzeigen, dass diese Coroutine keinen RĂĽckgabewert hat. - Behandlung von Ausnahmen: Die Funktion
unhandled_exception
wird aufgerufen, wenn eine unbehandelte Ausnahme innerhalb der Coroutine auftritt. Sie bietet einen Mechanismus an, um Ausnahmen elegant zu behandeln.
Aber wo wird der promise_type
verwendet? Ich kann das Wort "promise" im Beispielcode nicht finden. Beim Schreiben einer Coroutine sieht der Compiler den Code etwas anders. Der vereinfachte Blick des Compilers fĂĽr simpleCoroutine
ist folgender:
MyCoroutine simpleCoroutine() {
MyCoroutine::promise_type p();
MyCoroutine coro_obj = p.get_return_object();
try {
co_await p.inital_suspend();
std::cout << "Start coroutine\n";
co_await std::suspend_always{};
std::cout << "Resume coroutine\n";
} catch(...) {
p.unhandled_exception();
}
co_await p.final_suspend();
}
Deshalb muss promise_type
in der Klasse MyCoroutine
definiert sein. Wenn simpleCoroutine
aufgerufen wird, erstellt der Compiler einen promise_type
und ruft get_return_object()
auf, um das MyCoroutine
-Objekt zu erstellen. Vor dem Körper der Coroutine ruft der Compiler initial_suspend
auf, um festzustellen, ob die Coroutine zu Beginn angehalten werden soll. SchlieĂźlich ruft er final_suspend
auf, um festzustellen, ob die AusfĂĽhrung am Ende unterbrochen werden soll. Wer promise_type
und die entsprechenden Funktionen nicht definiert, erhält einen Compilerfehler.
Awaitable
Ein Awaitable steuert das Verhalten eines Suspensionspunktes. Drei Funktionen mĂĽssen fĂĽr ein Awaitable definiert werden:
await_ready
: Diese Funktion bestimmt, ob die Coroutine ohne Unterbrechung fortfahren kann. Sie solltetrue
zurĂĽckgeben, wenn die Operation sofort fortgesetzt werden kann, oderfalse
, wenn eine Unterbrechung erforderlich ist. Diese Methode ist eine Optimierung, mit der man die Kosten für eine Unterbrechung in Fällen vermeiden kann, in denen bekannt ist, dass der Vorgang synchron abgeschlossen werden wird.await_suspend
: Mit dieser Funktion kann man das Verhalten eines Suspensionspunktes genau steuern. Sie übergibt den aktuellen Coroutine-Handle, damit die Benutzer die Coroutine später wieder aufnehmen oder zerstören können. Für diese Funktion gibt es drei Rückgabetypen:
- void: Wir setzen die Coroutine aus. Die Kontrolle wird sofort an den Aufrufer der aktuellen Coroutine zurĂĽckgegeben.
bool
: Beitrue
unterbrechen wir die aktuelle Coroutine und geben die Kontrolle an den Aufrufer zurĂĽck; beifalse
setzen wir die aktuelle Coroutine fort.coroutine_handle
: Wir unterbrechen die aktuelle Coroutine und nehmen das zurĂĽckgegebene Coroutine-Handle wieder auf. Dies wird auch als Assymetric-Transfer bezeichnet.
await_resume:
Diese Funktion gibt an, welcher Wert an die Coroutine zurückgegeben werden soll, wenn die erwartete Operation abgeschlossen ist. Sie setzt die Ausführung der Coroutine fort und übergibt das erwartete Ergebnis. Wenn kein Ergebnis erwartet oder benötigt wird, kann diese Funktion leer sein undvoid
zurĂĽckgeben.
Aber wo werden diese Funktionen verwendet? Schauen wir uns noch einmal die Sicht des Compilers an. Wenn man co_await std:suspend_always{}
aufruft, wandelt der Compiler es in den folgenden Code Folgende um:
auto&& awaiter = std::suspend_always{};
if(!awaiter.await_ready()) {
awaiter.await_suspend(std::coroutine_handle<>...);
//<suspend/resume>
}
awaiter.await_resume();
Deshalb muss man alle diese Funktionen definieren. Der std::suspend_always
ist ein in C++ eingebauter Awaiter, der die Funktionen wie folgt definiert:
struct suspend_always {
constexpr bool await_ready() const noexcept { return false; }
constexpr void await_suspend(coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {}
};
Coroutine-Handle
Coroutine-Handles werden verwendet, um den Zustand und den Lebenszyklus einer Coroutine zu verwalten. Sie bieten eine Möglichkeit, Coroutinen explizit aufzurufen, fortzusetzen und zu zerstören. Im Beispiel rufe ich handle.resume()
auf, um die Coroutine fortzusetzen und handle.destroy()
, um die Coroutine zu zerstören.
Das Ergebnis der ProgrammausfĂĽhrung sieht folgendermaĂźen aus:
Wie geht's weiter?
Wie versprochen war dieser Artikel von Dian-Lun Li eine kompakte Einführung in Coroutinen. Im nächsten Artikel wendet Dian-Lun die Theorie an, um einen Single-Thread-Scheduler für C++-Coroutinen zu implementieren.
Embedded Programmierung mit modernem C++-Schulung
- Embedded Programmierung mit modernem C++: 12.12.2023 - 14.12.2023 (Präsenzschulung, Termingarantie)
(rme)