Zum Inhalt springen

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:

generator.cpp
#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:

co_await_example.cpp
#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.

Best Practice

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.

Stolperfalle

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.

Wichtigste Erkenntnisse
  • Coroutines sind unterbrechbare Funktionen — sie pausieren mit co_yield/co_await und laufen später weiter
  • Das Generator-Muster erzeugt Lazy Sequences: Jedes co_yield unterbricht und liefert einen Wert
  • co_await pausiert, bis eine asynchrone Operation fertig ist — die drei Methoden des Awaitable steuern das Verhalten
  • promise_type und coroutine_handle bilden 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?