Chapter 22: Coroutines
Coroutines in C++ suspend and resume functions at well defined points so that work can be interleaved without extra threads. The language keywords co_await, co_yield, and co_return pair with user types that model the protocol. This chapter explains the building blocks, then shows how to design libraries that feel natural and safe.
The coroutine model and promise types
When a function uses co_await, co_yield, or co_return, the compiler transforms it into a state machine. The return type defines an inner promise_type that controls creation, suspension, completion, and exception flow. A std::coroutine_handle<P> is the opaque control handle for a coroutine whose promise is P.
Lifecycle and involved types
The transformed function allocates a coroutine frame that stores locals and bookkeeping. The promise is constructed first; the initial suspend point runs next; then the body executes and may suspend many times. On final suspend the coroutine either destroys itself or stays alive until the handle is destroyed. Exceptions are routed to promise_type::unhandled_exception.
| Element | Role |
return_object | Concrete object returned by the coroutine function |
initial_suspend() | Choose to start suspended or run immediately |
final_suspend() | Choose to suspend at end so observers can react |
get_return_object() | Construct the object the caller receives |
return_void or return_value | Supply the result at completion |
unhandled_exception() | Capture exceptions to deliver later |
A minimal generator<T> shell
This sketch shows the promise hooks and how co_yield communicates a value. Real implementations add iteration support and memory management; the outline highlights the moving parts.
#include <coroutine>
#include <optional>
template <class T>
struct generator {
struct promise_type {
std::optional<T> current;
generator get_return_object() { return generator{handle_type::from_promise(*this)}; }
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T v) noexcept { current = std::move(v); return {}; }
void return_void() noexcept {}
void unhandled_exception() { ex = std::current_exception(); }
std::exception_ptr ex{};
};
using handle_type = std::coroutine_handle<promise_type>;
explicit generator(handle_type h) : h(h) {}
generator(generator&& o) noexcept : h(std::exchange(o.h, {})) {}
~generator() { if (h) h.destroy(); }
bool next() {
if (!h || h.done()) return false;
h.resume();
if (h.promise().ex) std::rethrow_exception(h.promise().ex);
return !h.done();
}
T& value() { return *h.promise().current; }
private:
handle_type h{};
};
std::coroutine_handle by policy. Decide who calls destroy() and document that clearly.
Handles and frame ownership
A handle is a lightweight pointer to the frame; it does not own memory by itself. Destruction policy lives outside the handle. Choose one of two styles: the return object destroys in its destructor, or an external scheduler stores handles and controls lifetime. Both styles are valid when the contract is explicit.
Generators and async workflows
Generators produce a sequence lazily with co_yield. Async workflows represent pending results that complete later. Both are coroutines, yet they use different promise contracts and await behaviors.
Iterating a generator
A friendly generator supports range based for. The iterator adaptor drives resume() on increment and reads the yielded value on dereference. The example emits squares on demand.
#include <coroutine>
#include <utility>
generator<int> squares(int n) {
for (int i = 1; i <= n; ++i) {
co_yield i * i;
}
}
A simple task<T> for async
An async task holds either a result or an exception and can be awaited by other coroutines. The await_suspend hook decides how to schedule the continuation; for now we resume inline to keep the example compact.
#include <coroutine>
#include <exception>
#include <optional>
template <class T>
struct task {
struct promise_type {
std::optional<T> value;
std::exception_ptr ex;
task get_return_object() { return task{handle::from_promise(*this)}; }
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(T v) noexcept { value = std::move(v); }
void unhandled_exception() noexcept { ex = std::current_exception(); }
};
using handle = std::coroutine_handle<promise_type>;
explicit task(handle h) : h(h) {}
task(task&& o) noexcept : h(std::exchange(o.h, {})) {}
~task() { if (h) h.destroy(); }
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> cont) noexcept { cont.resume(); }
T await_resume() {
if (h.promise().ex) std::rethrow_exception(h.promise().ex);
return std::move(*h.promise().value);
}
private:
handle h{};
};
task<int> compute() {
co_return 42;
}
Composing async coroutines
Awaiting a task<T> yields a value and propagates exceptions. This makes request pipelines read like straight line code. Add timeouts, cancellation, and retries around awaits to complete the story.
task<int> doubled() {
int v = co_await compute();
co_return v * 2;
}
Awaitables and schedulers
An awaitable is any type that provides the await trio: await_ready, await_suspend, and await_resume. A scheduler decides where and when a suspended coroutine continues. Together they route work to threads, event loops, or I/O completion ports.
The awaitable contract
The contract is small and powerful. If await_ready returns true, the coroutine does not suspend. Otherwise await_suspend receives the awaiting handle and can arrange resumption later. await_resume returns the result or throws to signal failure.
struct immediate_int {
int v;
bool await_ready() const noexcept { return true; }
void await_suspend(std::coroutine_handle<>) noexcept {}
int await_resume() const noexcept { return v; }
};
Posting continuations to a thread pool
Schedulers usually keep a queue of continuations. When an event fires, the scheduler calls resume() on the saved handle from a worker thread. The small adapter below posts the continuation through a user supplied function.
#include <functional>
struct post_awaitable {
std::function<void(std::coroutine_handle<>)> post;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) const { post(h); }
void await_resume() const noexcept {}
};
Bridging callbacks to co_await
Legacy APIs signal completion via callbacks. Wrap them by capturing a std::coroutine_handle<> and resuming it inside the callback. Deliver results through shared state that await_resume reads. Add an atomic flag to guard against double resume and to handle cancellation correctly.
Designing coroutine friendly libraries
Libraries should expose ergonomic types that fit the coroutine protocol and integrate with scheduling. Focus on clear ownership, predictable cancellation, structured completion, and explicit context switching. Prefer value oriented APIs with small awaitables over giant framework objects.
Ownership and cancellation
Decide who owns the coroutine frame and who can cancel. Provide a token or stop source that propagates through nested awaits. When cancelled, complete the awaitable promptly and report a distinct error so callers can separate cancellation from failure.
Error handling and results
Coroutines can propagate exceptions through await_resume, or they can return tagged results such as expected<T,E>. Choose one style for a library and apply it everywhere; mixing strategies increases cognitive load.
Scheduler aware APIs
Accept an execution context parameter or capture one at construction. Document where continuations run. Many bugs arise from code that resumes on surprising threads. Provide schedule() or via(...) helpers to move execution between contexts when needed.
Interoperability contracts
Ensure awaitables are await-transform friendly. Provide operator co_await so callers can customize behavior. Keep awaiters trivially movable and noexcept where possible so that compilers generate lean state machines.
Testing and diagnostics
Add deterministic tests by using fake schedulers that run continuations in a controlled order. Log suspend and resume with handle addresses to trace flows. Provide counters for posted tasks, in flight operations, and cancellations to spot leaks early.
Coroutines offer direct expression of asynchronous logic with fewer layers. With clear ownership, simple awaitables, and scheduler aware design, coroutine based code stays readable and efficient.