Vai al contenuto

Le Coroutine di C++20

Comprendi le coroutine come funzioni sospendibili. Impara co_yield, co_return, co_await, il pattern generator e casi d'uso pratici come l'I/O asincrono e le macchine a stati.

Che Cosa Sono le Coroutine?

Una coroutine è una funzione che può sospendere la propria esecuzione ed essere ripresa in seguito. A differenza delle funzioni normali, che vengono eseguite dall'inizio alla fine, le coroutine possono mettersi in pausa a metà esecuzione, restituire risultati intermedi e proseguire da dove si erano interrotte. Questo è profondamente diverso dai thread: le coroutine sono cooperative (scelgono loro quando sospendersi), leggere (non serve un thread del sistema operativo) e deterministiche (nessuna data race dovuta ad accessi concorrenti).

Il C++20 introduce il supporto alle coroutine a livello di linguaggio con tre nuove parole chiave: co_yield (sospende e produce un valore), co_return (termina la coroutine con un valore finale) e co_await (sospende finché un'operazione asincrona non è completata). Qualsiasi funzione che contiene una di queste parole chiave è automaticamente una coroutine.

Il Pattern Generator

Un generator è una coroutine che produce una sequenza di valori in modo pigro. Ogni chiamata a co_yield sospende la coroutine e consegna un valore al chiamante. Ecco un'implementazione completa e compilabile di un generator:

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;
}

La Macchina delle Coroutine: Promise e Handle

Ogni coroutine ha due componenti fondamentali:

promise_type — Controlla il comportamento della coroutine. Il compilatore cerca un promise_type annidato nel tipo di ritorno della coroutine. Metodi principali:
- get_return_object() — crea l'oggetto restituito al chiamante
- initial_suspend() — restituisce suspend_always (avvio pigro) o suspend_never (avvio immediato)
- final_suspend() — cosa fare quando la coroutine termina (deve essere noexcept)
- yield_value(T) — chiamato su co_yield
- return_value(T) o return_void() — chiamato su co_return
- unhandled_exception() — chiamato se un'eccezione sfugge

std::coroutine_handle

— Un puntatore con tipo cancellato al frame della coroutine. Offre i metodi resume(), done() e destroy(). Il frame della coroutine vive sull'heap e contiene tutte le variabili locali e il punto di sospensione.

Il flusso è: il chiamante crea la coroutine -> initial_suspend -> il chiamante invoca resume() -> la coroutine prosegue fino a co_yield/co_return/co_await -> il controllo torna al chiamante -> si ripete.

co_await e Oggetti Awaitable

co_await sospende una coroutine finché un'operazione asincrona non è completata. Qualsiasi tipo che implementa l'interfaccia Awaitable può essere usato con co_await. Questa è la base dei framework di I/O asincrono:

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;
}

Casi d'Uso Pratici

Le coroutine danno il meglio in diversi scenari reali:

Generator / Sequenze pigre — Producono valori su richiesta senza calcolare in anticipo l'intera sequenza. Ideali per sequenze infinite, parsing di file e pipeline di dati.

I/O asincrono — Server di rete, query su database e operazioni sui file possono fare co_await sul completamento senza bloccare i thread. Librerie come cppcoro, libunifex e Boost.Asio offrono primitive di I/O pronte per le coroutine.

Macchine a stati — Ogni co_yield o co_await rappresenta una transizione di stato. Le variabili locali della coroutine conservano naturalmente lo stato tra una transizione e l'altra, eliminando le struct di stato scritte a mano.

Programmazione a eventi — I framework per interfacce utente e i game loop possono usare le coroutine per esprimere logica sequenziale che si estende su più frame o eventi.

Parsing — Tokenizer e parser possono fare co_yield di token o nodi dell'AST man mano che elaborano l'input, mantenendo implicitamente lo stato del parsing.

Buona pratica

Coroutine e thread risolvono problemi diversi:

Usa le coroutine quando: ti serve multitasking cooperativo, valutazione pigra o I/O asincrono senza l'overhead dei thread del sistema operativo. Le coroutine non hanno overhead di cambio di contesto, non richiedono primitive di sincronizzazione e possono scalare fino a milioni di task concorrenti.

Usa i thread quando: ti serve vero parallelismo tra più core della CPU per lavoro CPU-bound, oppure quando ti serve uno scheduling preemptive (una coroutine che non si sospende mai blocca il proprio thread).

Best practice: Usa le coroutine per la concorrenza I/O-bound e i thread per il parallelismo CPU-bound. Molti server ad alte prestazioni combinano i due approcci: un pool di thread esegue le coroutine, dove ogni coroutine gestisce una connessione usando co_await per l'I/O.

Insidia

Riferimenti pendenti: I parametri della coroutine vengono copiati nel frame, ma i riferimenti a oggetti temporanei possono diventare pendenti. Passa sempre i parametri per valore oppure assicurati che l'oggetto referenziato sopravviva alla coroutine.

Allocazione sull'heap: Ogni coroutine alloca un frame sull'heap. Per coroutine molto frequenti e di breve durata questo può essere un problema di prestazioni (anche se i compilatori possono ottimizzare tramite la Heap Allocation eLision Optimization — HALO).

Difficoltà di debug: Gli stack delle coroutine sono più difficili da ispezionare nei debugger. Lo stato sospeso si trova sull'heap, non sullo stack delle chiamate. Usa il logging e la concorrenza strutturata per mantenere il codice debuggabile.

Nessun tipo nella libreria standard: Il C++20 fornisce le primitive del linguaggio ma nessun tipo standard Generator o Task. Devi scriverteli o usare una libreria. Il C++23 aggiunge std::generator.

Concetti chiave
  • Le coroutine sono funzioni sospendibili — si mettono in pausa con co_yield/co_await e riprendono più tardi
  • Il pattern generator produce sequenze pigre: ogni co_yield sospende e consegna un valore
  • co_await sospende finché un'operazione asincrona non è completata — i tre metodi dell'awaitable ne controllano il comportamento
  • promise_type e coroutine_handle formano il meccanismo che collega la coroutine al suo chiamante
  • Le coroutine sono cooperative (nessuna prelazione) e leggere (nessun thread del sistema operativo) — ideali per la concorrenza I/O-bound
  • Il C++20 fornisce solo le primitive; per il codice di produzione usa std::generator (C++23) o librerie come cppcoro

Quiz — Metti alla prova le tue conoscenze

(20 XP)

1. Quale parola chiave rende una funzione una coroutine in C++20?

2. A che cosa serve `promise_type::yield_value(T)`?

3. In che cosa le coroutine differiscono dai thread?