C++20-Coroutines
Verstehe Coroutines als unterbrechbare Funktionen. Lerne co_yield, co_return, co_await, das Generator-Muster und praktische Anwendungsfälle wie asynchrone I/O und Zustandsautomaten kennen.
Was sind Coroutines?
Eine Coroutine ist eine Funktion, die ihre Ausführung unterbrechen und später fortsetzen kann. Anders als gewöhnliche Funktionen, die von Anfang bis Ende durchlaufen, können Coroutines mitten in der Ausführung pausieren, Zwischenergebnisse liefern und dort weitermachen, wo sie aufgehört haben. Das unterscheidet sich grundlegend von Threads: Coroutines sind kooperativ (sie entscheiden selbst, wann sie pausieren), leichtgewichtig (kein OS-Thread nötig) und deterministisch (keine Data Races durch nebenläufigen Zugriff).
C++20 führt Coroutine-Unterstützung auf Sprachebene mit drei neuen Schlüsselwörtern ein: co_yield (pausieren und einen Wert liefern), co_return (die Coroutine mit einem finalen Wert abschließen) und co_await (pausieren, bis eine asynchrone Operation fertig ist). Jede Funktion, die eines dieser Schlüsselwörter enthält, ist automatisch eine Coroutine.
Das Generator-Muster
Ein Generator ist eine Coroutine, die eine Wertefolge lazy erzeugt. Jeder co_yield-Aufruf unterbricht die Coroutine und liefert einen Wert an den Aufrufer. Hier ist eine vollständige, kompilierbare Generator-Implementierung:
#include <iostream>
#include <coroutine>
#include <optional>
// A simple Generator type
template<typename T>
struct Generator {
struct promise_type {
T current_value;
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T value) {
current_value = std::move(value);
return {};
}
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
~Generator() { if (handle) handle.destroy(); }
// Move only
Generator(Generator&& other) noexcept : handle(other.handle) { other.handle = nullptr; }
Generator& operator=(Generator&&) = delete;
Generator(const Generator&) = delete;
bool next() {
handle.resume();
return !handle.done();
}
T value() const { return handle.promise().current_value; }
};
// A coroutine that generates Fibonacci numbers
Generator<long long> fibonacci() {
long long a = 0, b = 1;
while (true) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// A coroutine that generates a range
Generator<int> range(int start, int end) {
for (int i = start; i < end; ++i) {
co_yield i;
}
}
int main() {
auto fib = fibonacci();
std::cout << "First 10 Fibonacci numbers: ";
for (int i = 0; i < 10 && fib.next(); ++i) {
std::cout << fib.value() << " ";
}
std::cout << "\n"; // 0 1 1 2 3 5 8 13 21 34
auto r = range(1, 6);
while (r.next()) {
std::cout << r.value() << " "; // 1 2 3 4 5
}
std::cout << "\n";
return 0;
}Coroutine-Maschinerie: Promise & Handle
Jede Coroutine hat zwei zentrale Bestandteile:
promise_type — Steuert das Verhalten der Coroutine. Der Compiler sucht nach einem eingebetteten promise_type im Rückgabetyp der Coroutine. Wichtige Methoden:
- get_return_object() — erzeugt das Objekt, das an den Aufrufer zurückgegeben wird
- initial_suspend() — liefert suspend_always (lazy Start) oder suspend_never (sofortiger Start)
- final_suspend() — was beim Ende der Coroutine geschehen soll (muss noexcept sein)
- yield_value(T) — wird bei co_yield aufgerufen
- return_value(T) oder return_void() — wird bei co_return aufgerufen
- unhandled_exception() — wird aufgerufen, wenn eine Exception entkommt
std::coroutine_handle — Ein typgelöschter Zeiger auf den Coroutine-Frame. Er bietet die Methoden resume(), done() und destroy(). Der Coroutine-Frame liegt auf dem Heap und enthält alle lokalen Variablen sowie den Unterbrechungspunkt.
Der Ablauf ist: Aufrufer erzeugt Coroutine -> initial_suspend -> Aufrufer ruft resume() -> Coroutine läuft bis co_yield/co_return/co_await -> Kontrolle geht an den Aufrufer zurück -> wiederholen.
co_await und Awaitable-Objekte
co_await unterbricht eine Coroutine, bis eine asynchrone Operation abgeschlossen ist. Jeder Typ, der die Awaitable-Schnittstelle implementiert, lässt sich mit co_await verwenden. Das ist die Grundlage für Frameworks zur asynchronen I/O:
#include <iostream>
#include <coroutine>
#include <chrono>
#include <thread>
// A simple awaitable that simulates async work
struct AsyncTimer {
std::chrono::milliseconds duration;
bool await_ready() const noexcept {
return duration.count() <= 0; // skip suspend if no wait needed
}
void await_suspend(std::coroutine_handle<> handle) const {
// In production, this would register with an event loop
// Here we simulate with a thread
std::thread([handle, d = duration]() {
std::this_thread::sleep_for(d);
handle.resume(); // resume the coroutine
}).detach();
}
void await_resume() const noexcept {
// Called when the coroutine resumes — return value becomes
// the result of the co_await expression
}
};
// The three Awaitable methods:
// 1. await_ready() — can we skip suspending? (optimization)
// 2. await_suspend() — what to do when suspending
// 3. await_resume() — what value to produce when resuming
// A Task type for async coroutines
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
Task async_work() {
std::cout << "Starting async work...\n";
co_await AsyncTimer{std::chrono::milliseconds(100)};
std::cout << "Phase 1 complete\n";
co_await AsyncTimer{std::chrono::milliseconds(100)};
std::cout << "Phase 2 complete\n";
}
int main() {
async_work();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
return 0;
}Praktische Anwendungsfälle
Coroutines glänzen in mehreren realen Szenarien:
Generatoren / Lazy Sequences — Werte bei Bedarf erzeugen, ohne die gesamte Folge vorab zu berechnen. Ideal für unendliche Folgen, das Parsen von Dateien und Datenpipelines.
Asynchrone I/O — Netzwerkserver, Datenbankabfragen und Dateioperationen können mit co_await auf den Abschluss warten, ohne Threads zu blockieren. Bibliotheken wie cppcoro, libunifex und Boost.Asio liefern coroutinefähige I/O-Primitive.
Zustandsautomaten — Jedes co_yield oder co_await steht für einen Zustandsübergang. Die lokalen Variablen der Coroutine bewahren den Zustand zwischen den Übergängen ganz von selbst, sodass manuelle Zustands-Structs entfallen.
Ereignisgesteuerte Programmierung — UI-Frameworks und Game-Loops können mit Coroutines sequenzielle Logik ausdrücken, die sich über mehrere Frames oder Ereignisse erstreckt.
Parsing — Tokenizer und Parser können Tokens oder AST-Knoten per co_yield ausgeben, während sie die Eingabe verarbeiten, und halten den Parse-Zustand dabei implizit fest.
Coroutines und Threads lösen unterschiedliche Probleme:
Verwende Coroutines, wenn: du kooperatives Multitasking, Lazy Evaluation oder asynchrone I/O ohne den Overhead von OS-Threads brauchst. Coroutines haben keinen Kontextwechsel-Overhead, benötigen keine Synchronisationsprimitive und skalieren bis zu Millionen gleichzeitiger Tasks.
Verwende Threads, wenn: du echte Parallelität über CPU-Kerne hinweg für rechenintensive Arbeit brauchst oder wenn du präemptives Scheduling benötigst (eine Coroutine, die nie pausiert, blockiert ihren Thread).
Best Practice: Nutze Coroutines für I/O-gebundene Nebenläufigkeit und Threads für CPU-gebundene Parallelität. Viele Hochleistungsserver kombinieren beides: Ein Thread-Pool führt Coroutines aus, wobei jede Coroutine eine Verbindung bedient und für I/O co_await verwendet.
Dangling-Referenzen: Coroutine-Parameter werden in den Frame kopiert, aber Referenzen auf Temporaries können ins Leere zeigen. Übergib Parameter immer by value oder stelle sicher, dass das referenzierte Objekt die Coroutine überlebt.
Heap-Allokation: Jede Coroutine allokiert einen Frame auf dem Heap. Bei sehr heißen, kurzlebigen Coroutines kann das ein Performanceproblem sein (auch wenn Compiler das per Heap Allocation eLision Optimization — HALO — wegoptimieren können).
Erschwertes Debugging: Coroutine-Stacks lassen sich im Debugger schwerer inspizieren. Der unterbrochene Zustand liegt auf dem Heap, nicht auf dem Call Stack. Setze auf Logging und strukturierte Nebenläufigkeit, um debugbar zu bleiben.
Keine Standardbibliothekstypen: C++20 liefert die Sprachprimitive, aber keinen standardisierten Generator- oder Task-Typ. Du musst selbst einen schreiben oder eine Bibliothek verwenden. C++23 ergänzt std::generator.
- Coroutines sind unterbrechbare Funktionen — sie pausieren mit
co_yield/co_awaitund laufen später weiter - Das Generator-Muster erzeugt Lazy Sequences: Jedes
co_yieldunterbricht und liefert einen Wert co_awaitpausiert, bis eine asynchrone Operation fertig ist — die drei Methoden des Awaitable steuern das Verhaltenpromise_typeundcoroutine_handlebilden die Maschinerie, die die Coroutine mit ihrem Aufrufer verbindet- Coroutines sind kooperativ (keine Präemption) und leichtgewichtig (kein OS-Thread) — ideal für I/O-gebundene Nebenläufigkeit
- C++20 liefert nur die Primitive; nutze
std::generator(C++23) oder Bibliotheken wie cppcoro für Produktivcode
Quiz — Teste dein Wissen
(20 XP)1. Welches Schlüsselwort macht eine Funktion in C++20 zu einer Coroutine?
2. Wozu dient `promise_type::yield_value(T)`?
3. Wie unterscheiden sich Coroutines von Threads?