C++20 Coroutines
Overview
C++20 introduces language-level stackless coroutines, controlling coroutine suspension and resumption through three keywords: co_await, co_yield, and co_return. The compiler saves coroutine execution state into a heap-allocated coroutine frame, which can be resumed at any time after suspension. The standard library did not provide std::generator until C++23; C++20's coroutine framework is a customizable low-level primitive — production use requires custom return types or third-party libraries (e.g., cppcoro, Boost.Asio). Core value: writing asynchronous logic in synchronous style, lazy generators (on-demand computation, O(1) memory), and high-concurrency task scheduling (no thread-switching overhead).
Coroutine Keywords and Suspension Points
Three keywords define coroutine behavior: co_await expr suspends waiting for an Awaiter to be ready; co_yield expr is equivalent to co_await promise.yield_value(expr) producing a value and suspending; co_return expr calls return_value(expr) (or return_void() when parameterless) and terminates the coroutine body.
The evaluation of co_await expr constitutes a suspension point. The compiler calls three methods following the Awaiter protocol:
struct Awaiter {
bool await_ready() const noexcept; // true → skip suspension, continue immediately
// await_suspend with three return types:
// void → unconditional suspension
// bool → resume immediately when false (conditional suspension)
// coroutine_handle → symmetric transfer (see below)
auto await_suspend(std::coroutine_handle<>) const noexcept;
auto await_resume() const noexcept; // value returned after resumption
};The standard library provides std::suspend_always (always suspends) and std::suspend_never (never suspends).
Generator<int> fibonacci() {
int a = 0, b = 1;
while (true) { co_yield a; auto tmp = a; a = b; b = tmp + b; }
}
Task<int> compute() {
int x = co_await async_read(); // suspend waiting for async I/O
co_return x * 2;
}promise_type
Every coroutine return type must have an embedded or trait-associated promise_type that defines the complete coroutine lifecycle protocol:
struct Task {
struct promise_type {
int result{};
Task get_return_object() { // construct the return object
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; } // lazy start
std::suspend_always final_suspend() noexcept { return {}; } // final suspension
void return_value(int v) { result = v; } // co_return expr
// void return_void() noexcept {} // co_return; or no co_return
std::suspend_always yield_value(int v) { // co_yield expr
result = v; return {};
}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
};Key constraint: return_value and return_void cannot coexist; the presence of co_yield implicitly allows return_void. The Awaiter returned by final_suspend() must be suspendable (await_ready() returns false); otherwise the coroutine frame is automatically destroyed and the handle becomes invalid (UB).
coroutine_handle
std::coroutine_handle<Promise> is a non-owning handle providing operations on the coroutine frame:
auto h = task.handle;
h.resume(); // resume execution from suspension point
h.done(); // query whether at final suspension point
h.destroy(); // destroy coroutine frame (must be in suspended state)
h.promise(); // access the promise object (requires concrete Promise type)
// Type-erased version: can resume, cannot access promise
std::coroutine_handle<void> erased = h;
// No-op handle, used to terminate symmetric transfer chains
std::coroutine_handle<> noop = std::noop_coroutine();coroutine_handle does not manage lifetime. The typical approach is an RAII wrapper that calls destroy() in its destructor.
Generator Pattern
C++20 requires hand-written generators; C++23 provides std::generator<T>:
template <typename T>
class Generator {
public:
struct promise_type {
T current{};
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() noexcept {}
void unhandled_exception() { std::terminate(); }
std::suspend_always yield_value(T value) {
current = std::move(value); return {};
}
};
struct iterator {
std::coroutine_handle<promise_type> h;
iterator& operator++() { h.resume(); return *this; }
const T& operator*() const { return h.promise().current; }
bool operator==(std::default_sentinel_t) const { return !h || h.done(); }
};
iterator begin() { if (handle) handle.resume(); return {handle}; }
std::default_sentinel_t end() { return {}; }
~Generator() { if (handle) handle.destroy(); }
Generator(Generator&& o) noexcept : handle(o.handle) { o.handle = nullptr; }
Generator(const Generator&) = delete;
private:
explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
std::coroutine_handle<promise_type> handle;
};
Generator<int> range(int lo, int hi) {
for (int i = lo; i < hi; ++i) co_yield i;
}
// for (int v : range(0, 10)) { /* 0, 1, ..., 9 */ }begin() performs the first resume() advancing to the first co_yield; each ++ advances to the next suspension point. In C++23, std::generator<T> can be used directly in place of the hand-written version.
Async Pattern
Wrap low-level asynchronous operations as Awaiters to write asynchronous code in synchronous style:
struct AsyncReadAwaiter {
int fd;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) {
register_io_callback(fd, [h]() { h.resume(); });
}
ssize_t await_resume() { return get_read_result(fd); }
};
Task<ssize_t> handle_connection(int fd) {
ssize_t n = co_await AsyncReadAwaiter{fd}; // suspend waiting for I/O ready
co_return n;
}Symmetric Transfer
Nested resume() calls cause the call stack to grow linearly. Symmetric transfer lets await_suspend return a coroutine_handle, and the compiler optimizes it as a tail call, keeping stack depth constant:
struct Task {
struct promise_type {
std::coroutine_handle<> continuation{};
// ... get_return_object, initial_suspend, return_void, unhandled_exception ...
auto final_suspend() noexcept {
struct FinalAwaiter {
bool await_ready() noexcept { return false; }
std::coroutine_handle<> await_suspend(
std::coroutine_handle<promise_type> h) noexcept {
return h.promise().continuation
? h.promise().continuation : std::noop_coroutine();
}
};
return FinalAwaiter{};
}
};
std::coroutine_handle<promise_type> handle;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<promise_type> await_suspend(
std::coroutine_handle<> caller) noexcept {
handle.promise().continuation = caller;
return handle; // symmetric transfer: direct jump, no call stack growth
}
};final_suspend returning continuation hands control back to the caller, avoiding stack overflow from A.resume() → B.resume() → C.resume() chains.
Best Practices
- Lazy start:
initial_suspend()returnssuspend_alwaysto avoid races caused by execution advancing to the suspension point immediately after construction. - Final suspension:
final_suspend()'sawait_ready()must returnfalse; otherwise accessing the handle after automatic destruction is UB. - Exception propagation: Capture and store exceptions in
unhandled_exception()usingstd::current_exception(), and rethrow inawait_resume(). - RAII manage handles: Call
destroy()in the return object's destructor; no bare handle leaks. - Symmetric transfer: Long coroutine chains must use symmetric transfer to avoid stack overflow, with
noop_coroutine()as the termination sentinel. - HALO verification: The compiler may allocate the coroutine frame on the caller's stack (HALO optimization), but this is an optimization, not a guarantee; benchmark performance-critical paths.
Common Pitfalls
- Dangling references:
co_awaitis a potential suspension point; after suspension, all externally referenced objects captured by reference must still be alive:cppTask dangerous(const std::string& input) { co_await some_async_op(); // input may have been destroyed externally process(input); // UB } // Fix: pass parameters by value, or copy to local variables before suspension - Heap allocation and HALO: Coroutine frames are typically heap-allocated by the compiler via
operator new. HALO can eliminate heap allocation when the lifetime can be statically analyzed, but lazy coroutines are harder to optimize than eagerly executed ones. initial_suspendand races: When usingsuspend_neverfor immediate execution, the caller may not be ready yet. Asynchronous tasks strongly recommend lazy start.- Missing
promise_typemembers: Missing any required member (get_return_object,initial_suspend,final_suspend,return_valueorreturn_void,unhandled_exception) will produce cryptic template errors. It is recommended to start from a minimal compilable template and expand incrementally. done()misinterpretation: When a coroutine is suspended atinitial_suspend,done()returnsfalsebut no values have been produced; when suspended atfinal_suspend,done()returnstrue. Iterators must check bothh.done()and handle validity.