Promise Types and the Coroutine Frame (C++20)
The keywords co_await, co_yield, and co_return are only half the story. For each coroutine to work, the compiler needs a promise type — a class the compiler reaches for behind the scenes to manage the coroutine's lifecycle, hold its value, and control suspension and resumption. C++20 deliberately provides the machinery to build these types rather than shipping a fixed set of coroutine types. Understanding the three moving parts — the promise object, the coroutine handle, and the coroutine frame — makes it possible to build custom coroutine types and to understand what third-party libraries like cppcoro are doing under the hood.
The three parts of every coroutine
When the compiler transforms a function into a coroutine it creates three distinct objects that collaborate at runtime. The promise object lives inside the coroutine and is the channel through which the coroutine communicates with its caller — it holds the yielded or returned value and decides what happens at each suspension point. The coroutine handle is a lightweight, non-owning reference held outside the coroutine; it lets the caller resume or destroy the coroutine. The coroutine frame is the heap-allocated block that ties everything together: it contains the promise object, the coroutine's parameters copied by value, local variables that outlive a suspension point, and a record of the current suspension point so resumption knows where to pick up.
Promise object
Inside the coroutine
Holds the value, controls initial/final suspension, propagates exceptions. Manipulated by the coroutine body.
Coroutine handle
Outside the coroutine
A lightweight token (essentially a pointer). Used by the caller to resume() or destroy() the coroutine.
Coroutine frame
Heap-allocated
Contains the promise, local variables that span suspension points, parameters, and the current suspension bookmark.
What cannot be a coroutine
Any function that contains co_await, co_yield, or co_return becomes a coroutine — but not all functions are eligible. The following cannot be coroutines, because the compiler cannot build a coroutine frame for them or because doing so would break fundamental language invariants:
- Constructors and destructors
- constexpr functions
- Functions with a variable number of arguments (variadic functions)
- Functions whose return type is auto or a concept type
- The main() function
The promise interface
When the compiler encounters a coroutine, it looks up std::coroutine_traits to find the promise type, then calls a fixed set of member functions at specific points in the coroutine's lifetime. These member functions form the promise interface. You implement them in your own promise class and the compiler calls them automatically — you never call them directly from user code.
| Member function | When the compiler calls it |
|---|---|
| Default constructor | The promise must be default-constructible; called automatically when the frame is created |
| initial_suspend() | Immediately after the frame is created — return suspend_always to defer the first step, suspend_never to run immediately |
| final_suspend() noexcept | After co_return or falling off the end — return suspend_always to keep the frame alive, suspend_never to destroy it immediately |
| unhandled_exception() | Called when an exception propagates out of the coroutine body uncaught |
| get_return_object() | Called once, before initial_suspend — produces the coroutine's return value (the task<T>, generator<T>, etc.) |
| return_value(v) | Enables the co_return v statement — store v here |
| return_void() | Enables the bare co_return statement — called for void coroutines |
| yield_value(v) | Enables co_yield v — store or forward v and return an awaitable |
The awaitable concept — what co_await needs
When the compiler sees co_await expr, it calls three member functions on the result of expr (or on an awaiter returned by operator co_await). Together these three functions implement the awaitable concept. The standard provides two ready-made awaitables: std::suspend_always (always suspends) and std::suspend_never (never suspends).
| Function | Called when | Role |
|---|---|---|
| await_ready() | Before potentially suspending | If true, skip suspension entirely — the result is ready now |
| await_suspend(handle) | When await_ready() returns false | Schedule resumption (or destroy the coroutine); receives the suspended coroutine's handle |
| await_resume() | When the coroutine is resumed | Returns the result of the entire co_await expression |
// The two standard awaitables
struct std::suspend_always {
bool await_ready() noexcept { return false; } // always suspend
void await_suspend(coroutine_handle<>) noexcept {}
void await_resume() noexcept {}
};
struct std::suspend_never {
bool await_ready() noexcept { return true; } // never suspend
void await_suspend(coroutine_handle<>) noexcept {}
void await_resume() noexcept {}
};Building a task<T> coroutine type
A task is the simplest useful coroutine type: it represents an asynchronous computation that starts executing when awaited and completes by returning a value via co_return. To build one you need three classes: a promise base (shared suspension policy), a typed promise that stores the value, and an awaiter that the task exposes so that one coroutine can co_await another. The outer task<T> class ties them together, holds the coroutine handle, and exposes resume(), is_ready(), and value() for the caller.
Step 1 — the promise base (shared lifecycle policy)
namespace details {
struct promise_base {
// Suspend at entry: the coroutine doesn't run until resume() is called
auto initial_suspend() noexcept { return std::suspend_always{}; }
// Suspend at exit: keep the frame alive so value() can be called after
auto final_suspend() noexcept { return std::suspend_always{}; }
// Unhandled exceptions terminate — a production implementation would
// store std::current_exception() and rethrow on value()
void unhandled_exception() { std::terminate(); }
};
}Step 2 — the typed promise (holds the value)
namespace details {
template <typename T>
struct promise final : public promise_base {
// Called once before initial_suspend — creates the task<T> the caller receives
auto get_return_object() {
return std::coroutine_handle<promise<T>>::from_promise(*this);
}
// Stores the value from co_return v
template <typename V,
typename = std::enable_if_t<std::is_convertible_v<V&&, T>>>
auto return_value(V&& value) noexcept(std::is_nothrow_constructible_v<T, V&&>) {
value_ = value;
return std::suspend_always{};
}
T get_value() const noexcept { return value_; }
private:
T value_;
};
}Step 3 — the task awaiter (enables co_await on a task)
// An awaiter is what co_await actually calls.
// The task exposes this via operator co_await() so that one
// coroutine can await another: auto v = co_await other_task();
struct task_awaiter {
std::coroutine_handle<promise_type> handle_;
bool await_ready() const noexcept { return !handle_ || handle_.done(); }
void await_suspend(std::coroutine_handle<> continuation) noexcept {
handle_.resume(); // start the awaited coroutine
}
decltype(auto) await_resume() {
if (!handle_) throw std::runtime_error{ "broken promise" };
return handle_.promise().get_value();
}
};Step 4 — the task type itself
template <typename T = void>
struct task {
using promise_type = details::promise<T>;
explicit task(std::coroutine_handle<promise_type> h) : handle_(h) {}
~task() { if (handle_) handle_.destroy(); }
// Move-only: the frame belongs to exactly one task
task(task&& t) noexcept : handle_(t.handle_) { t.handle_ = nullptr; }
task& operator=(task&& other) noexcept {
if (std::addressof(other) != this) {
if (handle_) handle_.destroy();
handle_ = other.handle_;
other.handle_ = nullptr;
}
return *this;
}
task(task const&) = delete;
task& operator=(task const&) = delete;
T value() const noexcept { return handle_.promise().get_value(); }
void resume() noexcept { handle_.resume(); }
bool is_ready() noexcept { return !handle_ || handle_.done(); }
auto operator co_await() const& noexcept { return task_awaiter{ handle_ }; }
private:
std::coroutine_handle<promise_type> handle_ = nullptr;
};Using task<T>
#include <coroutine>
task<int> get_answer() {
co_return 42;
}
task<> print_answer() {
auto t = co_await get_answer();
std::cout << "the answer is " << t << '\n';
}
// Utility: spin until the coroutine is done
// (main() cannot use co_await, so we drive it manually)
template <typename T>
void execute(T&& t) {
while (!t.is_ready()) t.resume();
}
int main() {
auto t = get_answer();
execute(t);
std::cout << "the answer is " << t.value() << '\n'; // 42
execute(print_answer()); // prints "the answer is 42"
}What the compiler does to a coroutine
When the compiler processes a coroutine it rewrites it into a state machine. The following pseudocode shows the essential transformation for a simple co_await expression — the real transformation is more involved but this captures the key idea:
// Source code:
task<> print_answer() {
auto t = co_await get_answer();
std::cout << "the answer is " << t << '\n';
}
// What the compiler approximately generates (pseudocode):
task<> print_answer() {
__frame* context = /* allocate coroutine frame */;
task<>::task_awaiter awaiter = operator co_await(get_answer());
if (!awaiter.await_ready()) {
std::coroutine_handle<> resume_co =
std::coroutine_handle<>::from_address(context);
awaiter.await_suspend(resume_co);
// — coroutine suspends here —
__suspend_resume_point_1:;
}
auto value = awaiter.await_resume();
std::cout << "the answer is " << value << '\n';
}In practice: reach for cppcoro or concurrencpp
Writing your own promise and task types is a cumbersome exercise — and a fragile one, because getting the lifetime management and exception propagation exactly right takes careful work. In production code, use a battle-tested library instead. The two most widely-used options are cppcoro (Lewis Baker, github.com/lewissbaker/cppcoro), which provides cppcoro::task<T>, cppcoro::generator<T>, cppcoro::sync_wait(), and a thread pool, and concurrencpp (David Haim, github.com/David-Haim/concurrencpp), which adds executors, timers, and a richer async runtime.
#include <cppcoro/task.hpp>
#include <cppcoro/sync_wait.hpp>
cppcoro::task<int> get_answer() {
co_return 42;
}
cppcoro::task<> print_answer() {
auto t = co_await get_answer();
std::println("the answer is {}", t);
co_await print_answer();
}
int main() {
// sync_wait() drives the coroutine from a non-coroutine context
cppcoro::sync_wait(print_answer());
}Key points
Promise type = your coroutine's control panel
initial_suspend, final_suspend, get_return_object, return_value / return_void / yield_value, unhandled_exception — implement these and the compiler wires everything together.
Coroutine handle = a non-owning pointer to the frame
Use std::coroutine_handle<P>::from_promise(*this) inside get_return_object() to get the handle that the task type will own.
suspend_always vs suspend_never
initial_suspend returning suspend_always means 'lazy — don't run until the caller resumes me'. final_suspend returning suspend_always means 'keep the frame alive after completion so value() can be called'.
Don't roll your own in production
cppcoro and concurrencpp provide production-quality task, generator, and async_generator types. Use them.