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:
#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:
#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.
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.
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.
- Le coroutine sono funzioni sospendibili — si mettono in pausa con
co_yield/co_awaite riprendono più tardi - Il pattern generator produce sequenze pigre: ogni
co_yieldsospende e consegna un valore co_awaitsospende finché un'operazione asincrona non è completata — i tre metodi dell'awaitable ne controllano il comportamentopromise_typeecoroutine_handleformano 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?