zurück zum Artikel

Softwareentwicklung: Ein kompakte Einführung in Coroutinen von Dian-Lun Li

Rainer Grimm

(Bild: heise online / anw)

Der Ausgangspunkt einer Miniserie zu einem Scheduler zum Verteilen von Tasks ist ein Gastbeitrag von Dian-Lun Li mit einer einfachen Umsetzung.

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 [1] 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 [2]".

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:

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:

  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.

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 [4])


URL dieses Artikels:
https://www.heise.de/-9356895

Links in diesem Artikel:
[1] https://www.grimm-jaud.de/index.php/blog/tag/coroutinen
[2] https://www.youtube.com/watch?v=kIPzED3VD3w
[3] https://www.modernescpp.de/index.php/c/2-c/46-embedded-programmierung-mit-modernem-c20230204145614
[4] mailto:rme@ix.de