Master C++20 Coroutines: Suspendable Functions from the Ground Up
Build intuition for C++20 coroutines by implementing a generator and async task, understanding promise_type, co_yield, and co_await end-to-end.
By the end of this page, you will understand what makes a function a coroutine, implement a reusable Generator<T> type from scratch, write a minimal Task<T> for async pipelines, recognize the three lifecycle control points the compiler calls on your behalf, and avoid the three most common lifetime traps.
What and Why
A regular function runs to completion and then returns. A coroutine is a function that can suspend mid-execution, yield control back to its caller, and later resume exactly where it left off β retaining all its local variables across suspensions.
This matters because many real-world problems are naturally expressed as sequences that produce values lazily (infinite ranges, database cursors, network streams) or as async workflows that wait on I/O without blocking a thread. Before C++20, both patterns required manual state machines, callbacks, or heavyweight threads. Coroutines give you the same result with straight-line, readable code.
C++20 introduces coroutines as a language mechanism, not a library: the compiler transforms any function that contains co_yield, co_await, or co_return into a state machine. The standard library deliberately ships no concrete coroutine types β you or a library author supplies those by implementing a promise_type.
Step by Step
Step 1 β The three keywords
| Keyword | What it does |
|---|---|
co_yield expr | Suspend and produce a value to the caller |
co_await expr | Suspend until some awaitable signals completion |
co_return expr | Finish the coroutine, optionally returning a value |
Using any one of them turns the surrounding function into a coroutine. The compiler then requires that the return type of that function expose a nested promise_type.
Step 2 β A minimal generator
promise_type is the hook the compiler uses to ask: "How do I create the return object? What do I do when the coroutine starts, yields, or finishes?"
#include <coroutine>
#include <iostream>
#include <utility>
// A simple owning wrapper around a coroutine handle.
struct Generator {
struct promise_type {
int current_value{};
Generator get_return_object() {
return Generator{Handle::from_promise(*this)};
}
// Suspend immediately so the body doesn't run until the caller asks.
std::suspend_always initial_suspend() noexcept { return {}; }
// Stay suspended after the final statement so we can query done().
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(int v) noexcept {
current_value = v;
return {};
}
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
using Handle = std::coroutine_handle<promise_type>;
explicit Generator(Handle h) : handle(h) {}
~Generator() { if (handle) handle.destroy(); }
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& o) noexcept : handle(std::exchange(o.handle, {})) {}
// Returns false when the coroutine body has finished.
bool next() { handle.resume(); return !handle.done(); }
int value() const { return handle.promise().current_value; }
private:
Handle handle;
};
Generator range(int first, int last) {
for (int i = first; i <= last; ++i)
co_yield i; // suspends here each iteration
}
int main() {
auto gen = range(1, 5);
while (gen.next())
std::cout << gen.value() << '\n'; // prints 1 2 3 4 5
}Compile with: g++ -std=c++20 -o gen gen.cpp
Notice that range looks like an ordinary loop. The compiler secretly allocates a heap frame to hold i, first, last, and a resume point. Each call to handle.resume() continues from the last suspension point.
Step 3 β Make it generic
Hardcoding int is unnecessary. Templatizing Generator only requires lifting current_value to type T:
#include <coroutine>
#include <optional>
#include <utility>
template<typename T>
struct Generator {
struct promise_type {
std::optional<T> current_value;
Generator get_return_object() {
return Generator{Handle::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
template<std::convertible_to<T> U>
std::suspend_always yield_value(U&& v) {
current_value = std::forward<U>(v);
return {};
}
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
using Handle = std::coroutine_handle<promise_type>;
explicit Generator(Handle h) : handle(h) {}
~Generator() { if (handle) handle.destroy(); }
Generator(const Generator&) = delete;
Generator(Generator&& o) noexcept : handle(std::exchange(o.handle, {})) {}
bool next() { handle.resume(); return !handle.done(); }
T& value() { return *handle.promise().current_value; }
private:
Handle handle;
};
Generator<std::string> greetings() {
co_yield "hello";
co_yield "world";
}
int main() {
auto g = greetings();
while (g.next())
puts(g.value().c_str());
}Common Patterns
Pattern 1 β Infinite lazy sequence
Because the coroutine suspends between yields, it is safe to loop forever:
#include <coroutine>
#include <cstdint>
// Re-use the Generator<T> template from Step 3 above.
Generator<uint64_t> fibonacci() {
uint64_t a = 0, b = 1;
for (;;) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// Caller decides how many terms it wants β the sequence never allocates ahead.Pattern 2 β Pipeline composition
Coroutines compose naturally: one generator feeds another.
Generator<int> take(Generator<int> src, int n) {
for (int i = 0; i < n && src.next(); ++i)
co_yield src.value();
}Pattern 3 β std::generator<T> from the standard library (C++23)
C++23 ships std::generator<T> in <generator>, so you no longer need to roll your own Generator type for simple use cases. It models ranges::view and ranges::input_range, which means you can pass it directly to range algorithms and pipe it into std::views pipelines.
#include <generator> // C++23
#include <iostream>
#include <ranges>
std::generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield a;
int next = a + b;
a = b;
b = next;
}
}
int main() {
// std::generator is a range β pipe it straight into views
for (int n : fibonacci() | std::views::take(8)) {
std::cout << n << ' '; // 0 1 1 2 3 5 8 13
}
}std::generator also supports recursive delegation via co_yield std::ranges::elements_of(range), which flattens a sub-generator into the parent's output without manual forwarding loops β useful for tree traversal:
std::generator<int> inorder(const Tree* node) {
if (!node) co_return;
co_yield std::ranges::elements_of(inorder(node->left)); // delegate
co_yield node->value;
co_yield std::ranges::elements_of(inorder(node->right)); // delegate
}Use std::generator when you want a lazily-produced sequence and your compiler supports C++23. Keep a hand-rolled Generator<T> only when you need co_await support, a custom allocator strategy, or C++20 compatibility.
Pattern 4 β Minimal async Task
co_await suspends on any type that satisfies the awaitable protocol (await_ready, await_suspend, await_resume). A trivial always-ready awaitable makes the mechanics visible:
#include <coroutine>
#include <iostream>
struct ImmediateAwaitable {
bool await_ready() const noexcept { return true; } // never actually suspends
void await_suspend(std::coroutine_handle<>) noexcept {}
int await_resume() const noexcept { return 42; }
};
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
};
Task run() {
int result = co_await ImmediateAwaitable{};
std::cout << "Got: " << result << '\n';
}
int main() { run(); }Real async frameworks (ASIO, liburing wrappers) supply awaitables that schedule the resume on I/O completion β the coroutine body stays linear while the machinery underneath is non-blocking.
Exception Handling in Coroutines
When an exception escapes the body of a coroutine the compiler does not let it propagate directly to the caller. Instead the generated code catches it and calls promise.unhandled_exception(). What happens next is entirely up to your promise_type.
The minimal generator above uses:
void unhandled_exception() noexcept { std::terminate(); }That is safe for generators that are not expected to throw, but it is a silent trap if they do. For a Task<T> type that must propagate errors to an await-ing coroutine, the standard pattern is to capture the live exception into a std::exception_ptr and rethrow it when the caller collects the result:
#include <coroutine>
#include <exception>
#include <stdexcept>
template<typename T>
struct Task {
struct promise_type {
T result{};
std::exception_ptr error; // stores any uncaught exception
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(T v) { result = std::move(v); }
// Called by the compiler inside a catch(...) block when an exception
// escapes the coroutine body. std::current_exception() is valid here.
void unhandled_exception() {
error = std::current_exception(); // capture; don't rethrow yet
}
};
using Handle = std::coroutine_handle<promise_type>;
explicit Task(Handle h) : handle(h) {}
~Task() { if (handle) handle.destroy(); }
// Drive the coroutine to completion and return its result (or rethrow).
T get() {
handle.resume();
if (handle.promise().error)
std::rethrow_exception(handle.promise().error); // rethrow here
return handle.promise().result;
}
private:
Handle handle;
};
Task<int> divide(int a, int b) {
if (b == 0) throw std::domain_error("division by zero");
co_return a / b;
}
int main() {
try {
auto t = divide(10, 0);
int v = t.get(); // rethrows domain_error
} catch (const std::exception& e) {
// caught here, not inside the coroutine
}
}Three key rules derived from cppreference and confirmed by compiler behaviour (GCC 11+, Clang 17+, MSVC):
unhandled_exception()is mandatory β omitting it is ill-formed; the entire coroutine body is wrapped in an implicittry/catch(...)that calls it.- Calling
std::terminate()(or anynoexceptno-op) is valid but silently swallows errors β only use it when exceptions truly cannot happen. - Capturing with
std::current_exception()and rethrowing later (as shown above) is the portable idiom for asyncTask<T>types; it preserves the original exception type and stack context.
Symmetric Transfer and Stack Safety
Consider a Task<T> coroutine that awaits another Task<T> in a loop:
task completes_synchronously() { co_return; }
task loop(int count) {
for (int i = 0; i < count; ++i)
co_await completes_synchronously(); // danger zone
}Without symmetric transfer, each co_await resumes completes_synchronously by calling coroutine_handle::resume() from inside await_suspend. When the inner coroutine finishes it calls resume() again to wake loop. Each pair of calls nests another frame on the call stack, so with count = 1'000'000 you get a stack overflow.
Symmetric transfer (introduced by P0913 for C++20) eliminates this by allowing await_suspend to return a std::coroutine_handle<> instead of calling resume() directly. The compiler lowers this to a guaranteed tail call β the current frame is popped before the next coroutine begins, so no stack accumulates regardless of chain length.
The signature that triggers symmetric transfer:
std::coroutine_handle<> await_suspend(std::coroutine_handle<> caller) noexcept;
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// returning a handle suspends *this* coroutine and resumes the returned one
// in a single O(1) tail-call β no extra stack frameA correct lazy Task that uses symmetric transfer in its final_awaiter:
#include <coroutine>
#include <utility>
struct Task {
struct promise_type {
std::coroutine_handle<> continuation; // set by the awaiting coroutine
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
// final_awaiter uses symmetric transfer to resume the continuation
struct FinalAwaiter {
bool await_ready() noexcept { return false; }
// Return the continuation handle β compiler turns this into a tail call
std::coroutine_handle<> await_suspend(
std::coroutine_handle<promise_type> h) noexcept {
auto cont = h.promise().continuation;
return cont ? cont : std::noop_coroutine();
}
void await_resume() noexcept {}
};
FinalAwaiter final_suspend() noexcept { return {}; }
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
using Handle = std::coroutine_handle<promise_type>;
explicit Task(Handle h) : handle(h) {}
~Task() { if (handle) handle.destroy(); }
Task(Task&& o) noexcept : handle(std::exchange(o.handle, {})) {}
// operator co_await stores the caller handle then resumes this task
auto operator co_await() && noexcept {
struct Awaiter {
Handle coro;
bool await_ready() noexcept { return false; }
// Symmetric transfer: store caller, resume inner task as tail call
std::coroutine_handle<> await_suspend(
std::coroutine_handle<> caller) noexcept {
coro.promise().continuation = caller;
return coro; // tail-call into the task body
}
void await_resume() noexcept {}
};
return Awaiter{handle};
}
private:
Handle handle;
};std::noop_coroutine() is the escape hatch: returning it from await_suspend means "suspend and return to whoever called resume()" without resuming any further coroutine.
When to apply this pattern: any time your Task-like type allows one coroutine to directly resume another β especially when loops or recursion are involved. Library types like asio::awaitable already apply symmetric transfer internally, so you only need to implement it yourself when building a custom coroutine framework.
Pattern 5 β Real Async I/O with Asio
The patterns above show the machinery; Asio (Boost.Asio or standalone Asio) shows what it looks like to use that machinery from the outside. Asio supplies its own awaitable types, so from your coroutine you just co_await socket operations directly.
The three Asio pieces you need:
| API | Role |
|---|---|
asio::awaitable<T> | Return type for a C++20 coroutine that Asio can schedule |
asio::co_spawn(executor, coro, token) | Launch an awaitable<T> on an executor |
asio::detached | Completion token that ignores the result (fire-and-forget) |
Minimal async TCP echo server (from the Boost.Asio docs):
#include <boost/asio.hpp>
#include <cstdio>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
// Each accepted connection runs in this coroutine.
// The return type asio::awaitable<void> marks it as an Asio C++20 coroutine.
asio::awaitable<void> echo(tcp::socket socket) {
try {
char data[1024];
for (;;) {
// co_await suspends until bytes arrive; returns byte count
std::size_t n = co_await socket.async_read_some(
asio::buffer(data)); // default: deferred token
// co_await suspends until all bytes are written
co_await asio::async_write(socket,
asio::buffer(data, n));
}
} catch (const std::exception& e) {
// I/O errors (EOF, reset) surface as exceptions via system_error
std::printf("echo: %s\n", e.what());
}
}
asio::awaitable<void> listener(asio::io_context& ctx) {
tcp::acceptor acceptor(ctx, {tcp::v4(), 55555});
for (;;) {
tcp::socket socket = co_await acceptor.async_accept();
// Spawn each client on the same executor; detached = fire-and-forget
asio::co_spawn(ctx, echo(std::move(socket)), asio::detached);
}
}
int main() {
asio::io_context ctx;
asio::co_spawn(ctx, listener(ctx), asio::detached);
ctx.run(); // blocks until all work is done
}What this demonstrates about the coroutine model:
async_read_someandasync_writereturn awaitables;co_awaiton them suspends the coroutine and returns the thread to the event loop. When I/O completes, Asio callsresume()on the coroutine handle β the same mechanism asImmediateAwaitablefrom Pattern 4, but driven by the OS event loop rather than synchronous code.- Errors from failed operations are converted from
boost::system::error_codetostd::system_errorand thrown at theco_awaitexpression, so you handle them with normaltry/catchrather than manually checking error codes. co_spawnis the bridge between synchronousmain()and the coroutine world; it registers the coroutine with the executor and starts it on the nextrun()iteration.
Compile with: g++ -std=c++20 -lboost_system -o echo echo.cpp (or link standalone Asio with -DASIO_STANDALONE).
What Can Go Wrong
Mistake 1 β Forgetting initial_suspend
If initial_suspend returns std::suspend_never, the coroutine body starts running the moment the caller constructs the return object β before the caller can attach any context. For generators, almost always return std::suspend_always.
Mistake 2 β Destroying a non-final-suspended handle
// WRONG β destroys the handle before the coroutine has finished
Generator<int> gen = range(1, 10);
gen.handle.destroy(); // if the body is mid-execution, UBAlways let the Generator destructor handle lifetime, and only destroy after done() returns true or after you've stopped iterating.
Mistake 3 β Capturing a reference to a local across a suspension point
Generator<int*> bad() {
int local = 99;
co_yield &local; // caller resumes later β local is still alive here
// but if the Generator is moved or the frame is somehow invalidated,
// the pointer the caller holds becomes dangling
}Prefer yielding values, not pointers or references into the coroutine frame. If you must yield a view, document the lifetime contract explicitly.
Quick Reference
| Concept | Key type / keyword | Notes |
|---|---|---|
| Make a function a coroutine | co_yield / co_await / co_return | Any one is sufficient |
| Connect return type to machinery | promise_type nested struct | Must be public |
| Control start behaviour | initial_suspend() | suspend_always = lazy |
| Control end behaviour | final_suspend() | suspend_always = safe to query done() |
| Produce a value and pause | co_yield expr | Calls promise.yield_value(expr) |
| Await an async result | co_await awaitable | Calls await_ready/suspend/resume |
| Finish with a value | co_return expr | Calls promise.return_value(expr) |
| Resume / check status | handle.resume() / handle.done() | Always check done() before resuming |
| Clean up the frame | handle.destroy() | Call exactly once, after coroutine is done or abandoned |
| Standard generator (C++23) | std::generator<T> in <generator> | Models input_range; use with range pipelines |
| Delegate sub-sequence | co_yield std::ranges::elements_of(rng) | Flattens a sub-generator inline (C++23) |
What's Next
- Coroutines reference β full grammar rules and the complete awaitable protocol
- Lambda advanced topics β coroutines and lambdas compose in subtle ways worth understanding
- Move semantics advanced β coroutine frames are move-only; understanding move semantics prevents ownership bugs
- Concepts advanced β constraining
TinGenerator<T>with concepts makes error messages dramatically clearer