C++20 Coroutines
Understand coroutines as suspendable functions. Learn co_yield, co_return, co_await, the generator pattern, and practical use cases like async I/O and state machines.
What Are Coroutines?
A coroutine is a function that can suspend its execution and be resumed later. Unlike regular functions that run from start to finish, coroutines can pause mid-execution, yield intermediate results, and continue where they left off. This is fundamentally different from threads: coroutines are cooperative (they choose when to suspend), lightweight (no OS thread needed), and deterministic (no data races from concurrent access).
C++20 introduces coroutine support at the language level with three new keywords: co_yield (suspend and produce a value), co_return (complete the coroutine with a final value), and co_await (suspend until an async operation completes). Any function containing one of these keywords is automatically a coroutine.
The Generator Pattern
A generator is a coroutine that produces a sequence of values lazily. Each call to co_yield suspends the coroutine and delivers a value to the caller. Here is a complete, compilable generator implementation:
#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 Machinery: Promise & Handle
Every coroutine has two key components:
promise_type — Controls the coroutine's behavior. The compiler looks for a nested promise_type in the coroutine's return type. Key methods:
- get_return_object() — creates the object returned to the caller
- initial_suspend() — returns suspend_always (lazy start) or suspend_never (eager start)
- final_suspend() — what to do when the coroutine ends (must be noexcept)
- yield_value(T) — called on co_yield
- return_value(T) or return_void() — called on co_return
- unhandled_exception() — called if an exception escapes
std::coroutine_handle — A type-erased pointer to the coroutine frame. Provides resume(), done(), and destroy() methods. The coroutine frame lives on the heap and contains all local variables and the suspension point.
The flow is: caller creates coroutine -> initial_suspend -> caller calls resume() -> coroutine runs until co_yield/co_return/co_await -> control returns to caller -> repeat.
co_await and Awaitable Objects
co_await suspends a coroutine until an asynchronous operation completes. Any type that implements the Awaitable interface can be used with co_await. This is the foundation for async I/O frameworks:
#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;
}Practical Use Cases
Coroutines excel in several real-world scenarios:
Generators / Lazy Sequences — Produce values on demand without computing the entire sequence upfront. Ideal for infinite sequences, file parsing, and data pipelines.
Async I/O — Network servers, database queries, and file operations can co_await completion without blocking threads. Libraries like cppcoro, libunifex, and Boost.Asio provide coroutine-ready I/O primitives.
State Machines — Each co_yield or co_await represents a state transition. The coroutine's local variables naturally preserve state between transitions, eliminating manual state structs.
Event-Driven Programming — UI frameworks and game loops can use coroutines to express sequential logic that spans multiple frames or events.
Parsing — Tokenizers and parsers can co_yield tokens or AST nodes as they process input, maintaining parsing state implicitly.
Coroutines and threads solve different problems:
Use coroutines when: you need cooperative multitasking, lazy evaluation, or async I/O without the overhead of OS threads. Coroutines have no context-switch overhead, no synchronization primitives needed, and can scale to millions of concurrent tasks.
Use threads when: you need true parallelism across CPU cores for CPU-bound work, or when you need preemptive scheduling (a coroutine that never suspends will block its thread).
Best practice: Use coroutines for I/O-bound concurrency and threads for CPU-bound parallelism. Many high-performance servers combine both: a thread pool runs coroutines, where each coroutine handles one connection using co_await for I/O.
Dangling references: Coroutine parameters are copied into the frame, but references to temporaries can dangle. Always take parameters by value or ensure the referenced object outlives the coroutine.
Heap allocation: Every coroutine allocates a frame on the heap. For very hot, short-lived coroutines, this can be a performance concern (though compilers can optimize via Heap Allocation eLision Optimization — HALO).
Debugging difficulty: Coroutine stacks are harder to inspect in debuggers. The suspended state is on the heap, not the call stack. Use logging and structured concurrency to maintain debuggability.
No standard library types: C++20 provides the language primitives but no standard Generator or Task type. You must write your own or use a library. C++23 adds std::generator.
- Coroutines are suspendable functions — they pause with
co_yield/co_awaitand resume later - The generator pattern produces lazy sequences: each
co_yieldsuspends and delivers one value co_awaitsuspends until an async operation completes — the awaitable's three methods control the behavior- The
promise_typeandcoroutine_handleform the machinery that connects the coroutine to its caller - Coroutines are cooperative (no preemption) and lightweight (no OS thread) — ideal for I/O-bound concurrency
- C++20 provides primitives only; use
std::generator(C++23) or libraries like cppcoro for production code
Quiz — Test Your Knowledge
(20 XP)1. Which keyword makes a function a coroutine in C++20?
2. What is the purpose of `promise_type::yield_value(T)`?
3. How do coroutines differ from threads?