Skip to content
C++

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 functionWhen the compiler calls it
Default constructorThe 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() noexceptAfter 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).

FunctionCalled whenRole
await_ready()Before potentially suspendingIf true, skip suspension entirely — the result is ready now
await_suspend(handle)When await_ready() returns falseSchedule resumption (or destroy the coroutine); receives the suspended coroutine's handle
await_resume()When the coroutine is resumedReturns 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.

Sign in to track progress