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.

In Pocket speichern vorlesen Druckansicht 7 Kommentare lesen

(Bild: heise online / anw)

Lesezeit: 7 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

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.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

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 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.

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.

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 und final_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 ich return_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.

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 sollte true zurückgeben, wenn die Operation sofort fortgesetzt werden kann, oder false, 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:
  1. void: Wir setzen die Coroutine aus. Die Kontrolle wird sofort an den Aufrufer der aktuellen Coroutine zurückgegeben.
  2. bool: Bei true unterbrechen wir die aktuelle Coroutine und geben die Kontrolle an den Aufrufer zurück; bei false setzen wir die aktuelle Coroutine fort.
  3. 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 und void 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-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 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.

(rme)