C++20: Mehr Details zu Coroutinen

Modernes C++  –  8 Kommentare

Nachdem der letzte Artikel "C++20: Coroutinen – ein erster Überblick" in Coroutinen einführte, geht es heute um weitere Details. Gerne möchte ich wiederholen: Wir erhalten in C++20 keine Coroutinen, sondern ein Framework, um Coroutinen zu implementieren.

Mein Ziel in diesem und weiteren Artikeln ist es, dieses Framework zum Implementieren eigener Coroutinen zu erklären. Am Ende kannst du Coroutinen erzeugen oder existierende Implementierungen von Couroutinen wie die exzellente cppcoro-Umsetzung von Lewis Baker verwenden.

Der heutige Artikel ist ein Weder-noch-Artikel. Weder stellt dieser Artikel einen Überblick dar, noch steigt er tief in das Coroutinen-Framework ein.

Die erste Frage, die du zu Coroutinen hast, wird wohl sein: Wann sollten Coroutinen verwendet werden?

Typische Anwendungsfälle

Coroutinen werden gerne für Event-getriebene Applikationen verwendet. Diese können eine Simulation, ein Spiel, ein Server, ein Benutzerinterface oder auch ein Algorithmus sein. So schrieb ich zum Beispiel vor ein paar Jahren einen Simulator für einen Defibrillator. Der Defibrillator kam vor allem für die klinischen Usability-Tests zum Einsatz und ist eine Event-getriebenen Applikation. Daher setze ich ihn mithilfe des Event-getriebenen Frameworks twisted in Python um.

Coroutinen werden auch gerne für kooperatives Multitasking eingesetzt. Die zentrale Idee des kooperativen Multitaskings ist es, dass sich jeder Task so viel Zeit nimmt, wie er benötigt. Kooperatives Multitasking unterscheidet sich vom präemptiven Multitasking darin, dass ein Scheduler entscheidet, wie lange jeder Task die CPU erhält. Kooperatives Multitasking erlaubt es, Concurrency einfacher umzusetzen, da ein Task nicht in einem kritischen Bereich unterbrochen wird. Wenn du mehr Aufklärung zu den Begriffen kooperativ und präemptiv suchst, kann ich nur diesen exzellenten Überblickartikel empfehlen: "Cooperative vs. Preemptive: a quest to maximize concurrency power".

Grundlegende Ideen

Coroutinen in C++20 sind asymmetrisch, first-class und stackless:

  • Der Arbeitsablauf einer asymmetrischen Coroutinen geht zum Aufrufer zurück.
  • First-class-Coroutinen verhalten sich wie Daten. "Verhalten wie Daten" meint, das sich diese Coroutinen als Argument oder Rückgabewert einer Funktion verwenden lassen oder in einer Variable gespeichert werden können.
  • Eine Stackless-Coroutine erlaubt es, die Top-Level-Coroutinen zu pausieren und wieder zu starten. Die Ausführung der Coroutinen und deren Wert geht an den Aufrufer der Coroutine. Im Gegensatz dazu reserviert eine Stackful-Coroutine einen Stack von 1 MByte auf Windows und 2 MByte auf Linux.

Designziele

Gor Nishanov, der maßgeblich an der Standardisierung von Coroutinen in C++ beteiligt ist, stellt die Designziele von Coroutinen vor. Sie sollen

  • be highly scalable (to billions of concurrent coroutines).
  • have highly efficient resume and suspend operations comparable in cost to the overhead of a function.
  • seamlessly interact with existing facilities with no overhead.
  • have open-ended coroutine machinery allowing library designers to develop coroutine libraries.
  • exposing various high-level semantics such as generators, goroutines, tasks and more.
  • usable in environments where exceptions are forbidden or not available.

Zur Coroutinen werden

Eine Funktion, die die Schlüsselworte co_return, co_yield oder co_return verwendet, wird automatisch zur Coroutine:

  • co_return: Eine Coroutine verwendet co_return als Rückgabeanweisung.
  • co_yield: Dank co_yield lässt sich ein unendlicher Datenstrom implementieren, von dem sukzessive der Wert angefragt werden kann. Der Rückgabetyp der Funktion generatorForNumbers(int begin, int inc = 1), die ich in dem letzten Artikel ("C++20: Coroutinen – ein erster Überblick") vorgestellt habe, ist ein Generator. Ein Generator besitzt einen speziellen Promise pro, sodass ein Aufruf co_yield i äquivalent zu pro.yield_value(i) ist. Unmittelbar nach dem Aufruf wird die Coroutinte schlafen gelegt.
  • co_await: co_await führt eventuell dazu, dass die Ausführung einer Coroutine angehalten oder wieder aufgenommen wird. Der Ausdruck exp in co_await exp ist ein sogenannter awaitable-Ausdruck sein. Dazu setzt exp ein spezifisches Interface um. Es besteht aus den Funktionen await_ready, await_suspend und await_resume.
Zwei Awaitables

Der C++20-Standard definiert bereits zwei Awaitables als elementare Bausteine: std::suspend_always und std::suspend_never.

  • std::suspend_always:
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 {}
};

std::suspend_always pausiert immer, da await_ready false zurückgibt. Genau das Gegenteil gilt für das zweite Awaitable std::suspend_never.

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

Ich hoffe, dass das folgende Beispiel es einfacher macht, diese Theorie zu verdauen. Ein Server ist das "hello world"-Beispiel für eine Coroutine.

Ein blockierender und ein wartender Server

Ein Server ist eine Event-getriebene Applikation. Er wartet typischerweise in einer Evenschleife auf Clientanfragen.Der folgende Codeschnipsel stellt die Struktur eines einfachen Servers vor:

Acceptor acceptor{443};               // (1)

while (true){
Socket socket= acceptor.accept(); // blocking (2)
auto request= socket.read(); // blocking (3)
auto response= handleRequest(request);
socket.write(response); // blocking (4)
}

Der sequenzielle Server beantwortet jede Clientanfrage in demselben Thread. Der Server lauscht auf den Post 443 (Zeile 1), nimmt jede Verbindung an (Zeile 2), liest die ankommenden Daten von dem Client (Zeile 3) ein und schickt seine Antwort an den Client zurück (Zeile 4). Die Aufrufe in den Zeilen 2 bis 4 sind blockierend.

Dank co_await lassen sich die blockierenden Aufrufe einfach pausieren und wieder aufnehmen. Der ressourcenintensive blockierende Server wird dadurch zum ressourcenschonenden wartenden Server:

Acceptor acceptor{443};

while (true){
Socket socket= co_await acceptor.accept();
auto request= co_await socket.read();
auto response= handleRequest(request);
co_await socket.write(response);
}

Du vermutest es wohl schon. Der entscheidende Ausdruck, um Coroutinen zu verstehen, sind die awaitable-Ausdrücke expr in co_await expr. Sie müssen die Funktionen await_ready, await_suspend und await_resume umsetzen.

Wie geht's weiter?

Das Framework für das Schreiben von Coroutinen besteht aus mehr als 20 Funktionen. Diese gilt es zumindest teilweise zu implementieren oder zur überladen. Mit meinem nächsten Artikel tauche ich tiefer in das Framework ein.

Online-C++-Schulungen und ein paar persönliche Worte

Aufgrund des Coronavirus biete ich alle meine Schulungen jetzt auch online an. Ich halte bereits seit mehr als 10 Jahren Online-Seminare. Dank der modernen Webkonferenz-Werkzeuge ist ein Online-Seminar mehr als ein Ersatz für eine Präsenzschulung. Ein Online-Seminar bietet Mehrwert gegenüber einer Präsenzschulung an.

Online-Seminare:

Wir sollten die aktuelle Krise als Chance sehen und nutzen, analoge Muster durch digitale Lösungen zu ersetzen und das Mehr an Zeit sinnvoll zu investieren. Eine Krise lässt sich nicht durch Aussitzen lösen. Ich habe die Preise für meine Online-Seminare während der Krise deutlich reduziert. Wem der Preis noch zu hoch ist, der kann direkt mit mir (schulung@ModernesCpp.de) Kontakt aufnehmen.