Skip to content
C++
Language
since C++20
Expert

Coroutines Internals: promise_type, Awaitables, and coroutine_handle

How C++20 coroutines work under the hood: the coroutine frame, promise_type protocol, awaitable interface (await_ready/await_suspend/await_resume), and building a Generator and Task from scratch.

C++20 coroutines are a language feature that lets a function suspend and resume without a separate thread. The co_await, co_yield, and co_return keywords are hooks that call into a promise_type you define. Understanding the protocol lets you build generators, async tasks, and lazy sequences from first principles.


What the compiler does to a coroutine

When the compiler sees a function containing co_await, co_yield, or co_return, it transforms it into a state machine. The transformation creates:

  1. A coroutine frame β€” heap-allocated state holding local variables and the current suspension point
  2. A promise object β€” an instance of YourReturnType::promise_type, living in the frame
  3. A coroutine_handle<promise_type> β€” a lightweight handle (pointer-sized) used to resume or destroy the coroutine
cpp
// What you write:
Generator<int> count_up(int start) {
    for (int i = start; ; ++i)
        co_yield i;
}

// What the compiler roughly generates:
struct count_up_frame {
    Generator<int>::promise_type promise;
    int start;   // parameter
    int i;       // local variable
    int suspension_point;   // which co_yield we're at
};

Generator<int> count_up(int start) {
    auto* frame = new count_up_frame{};
    frame->start = start;
    auto handle = coroutine_handle<...>::from_promise(frame->promise);
    auto ret = frame->promise.get_return_object();  // creates the Generator<int>
    co_await frame->promise.initial_suspend();       // may suspend immediately
    // ... body runs until co_yield / co_return ...
    return ret;
}

The promise_type protocol

Every coroutine return type R must expose R::promise_type. The compiler calls methods on this type to drive the coroutine's lifecycle:

cpp
struct MyPromise {
    // 1. Called before the coroutine body runs.
    //    Return std::suspend_always to suspend immediately (lazy);
    //    return std::suspend_never  to run immediately (eager).
    std::suspend_always initial_suspend() noexcept { return {}; }

    // 2. Called when co_return is hit or the body completes.
    //    Same choice: suspend (caller inspects result) or don't.
    std::suspend_always final_suspend() noexcept { return {}; }

    // 3. Returns the object the caller receives from the coroutine call.
    //    Called before the body runs.
    ReturnType get_return_object() {
        return ReturnType{ handle_t::from_promise(*this) };
    }

    // 4. Called when the coroutine body completes normally (co_return; or fall-off).
    void return_void() {}      // for coroutines that don't co_return a value
    // OR:
    void return_value(T v) {}  // for coroutines that co_return a value

    // 5. Called when an unhandled exception escapes the body.
    void unhandled_exception() {
        except_ = std::current_exception();
    }

    std::exception_ptr except_;
};

The full lifecycle:

cpp
call coroutine()
  β†’ allocate frame
  β†’ construct promise
  β†’ get_return_object() β†’ caller gets the return value
  β†’ co_await initial_suspend()  ← may suspend here
  β†’ body runs ...
    β†’ co_yield x calls yield_value(x) then suspends
    β†’ co_await expr calls the awaitable protocol
    β†’ co_return v calls return_value(v)
  β†’ co_await final_suspend()   ← may suspend here
  β†’ frame destroyed (unless final_suspend suspends β†’ manual cleanup needed)

Building a Generator<T> from scratch

A generator yields values lazily on demand β€” co_yield suspends and hands a value to the caller:

cpp
#include <coroutine>
#include <optional>
#include <stdexcept>

template<typename T>
class Generator {
public:
    struct promise_type {
        T current_value;
        std::exception_ptr exception;

        Generator get_return_object() {
            return Generator{handle_t::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend()   noexcept { return {}; }

        // co_yield x β†’ stores x, suspends
        std::suspend_always yield_value(T value) noexcept {
            current_value = std::move(value);
            return {};
        }

        void return_void() noexcept {}
        void unhandled_exception() { exception = std::current_exception(); }
    };

    using handle_t = std::coroutine_handle<promise_type>;

    explicit Generator(handle_t h) : handle_(h) {}
    ~Generator() { if (handle_) handle_.destroy(); }

    Generator(Generator&&) = default;
    Generator(const Generator&) = delete;

    // Advance to the next value; returns false when done
    bool next() {
        handle_.resume();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return !handle_.done();
    }

    const T& value() const { return handle_.promise().current_value; }

    // Range-based for support:
    struct iterator {
        Generator* gen;
        bool done;
        iterator& operator++()    { done = !gen->next(); return *this; }
        const T& operator*()      { return gen->value(); }
        bool operator!=(std::default_sentinel_t) const { return !done; }
    };
    iterator begin() { auto ok = next(); return {this, !ok}; }
    std::default_sentinel_t end() { return {}; }

private:
    handle_t handle_;
};

Usage:

cpp
Generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

for (int n : fibonacci() | std::views::take(10)) {
    std::print("{} ", n);
}
// 0 1 1 2 3 5 8 13 21 34

The awaitable protocol (co_await)

When you write co_await expr, the compiler calls three methods on the awaiter (which may be expr itself or the result of expr.operator co_await()):

cpp
struct MyAwaitable {
    // 1. Should we skip suspension entirely?
    //    Return true β†’ don't suspend, go straight to await_resume()
    bool await_ready() const noexcept { return false; }

    // 2. Called with the coroutine handle right before suspending.
    //    Options:
    //    - void return     β†’ suspend unconditionally
    //    - bool return     β†’ suspend if true, continue if false
    //    - handle return   β†’ resume the returned handle immediately (symmetric transfer)
    void await_suspend(std::coroutine_handle<> handle) {
        // Schedule 'handle' to be resumed later (e.g. post to thread pool)
        thread_pool.post(handle);
    }

    // 3. Called when the coroutine is resumed.
    //    The return value becomes the value of the co_await expression.
    int await_resume() { return result_; }

    int result_;
};

Built-in awaitables:

  • std::suspend_always β€” always suspends (await_ready returns false, await_resume returns void)
  • std::suspend_never β€” never suspends (await_ready returns true)

Building an async Task<T>

A Task<T> represents an async operation that produces a T. It suspends immediately (initial_suspend returns std::suspend_always) and resumes its awaiting caller when done:

cpp
#include <coroutine>
#include <exception>
#include <variant>

template<typename T>
class Task {
public:
    struct promise_type {
        std::variant<std::monostate, T, std::exception_ptr> result;
        std::coroutine_handle<> continuation;   // the awaiting coroutine

        Task get_return_object() {
            return Task{handle_t::from_promise(*this)};
        }

        std::suspend_always  initial_suspend() noexcept { return {}; }

        // On completion: resume whoever is awaiting us
        struct FinalAwaiter {
            bool await_ready()  noexcept { return false; }
            void await_resume() noexcept {}
            std::coroutine_handle<> await_suspend(handle_t h) noexcept {
                // Symmetric transfer: resume the continuation without growing the stack
                if (auto cont = h.promise().continuation)
                    return cont;
                return std::noop_coroutine();
            }
        };
        FinalAwaiter final_suspend() noexcept { return {}; }

        void return_value(T v) { result.template emplace<1>(std::move(v)); }
        void unhandled_exception() {
            result.template emplace<2>(std::current_exception());
        }
    };

    using handle_t = std::coroutine_handle<promise_type>;

    explicit Task(handle_t h) : handle_(h) {}
    ~Task() { if (handle_) handle_.destroy(); }
    Task(Task&&) = default;
    Task(const Task&) = delete;

    // Awaitable interface: lets other coroutines co_await on a Task
    bool await_ready() const noexcept { return false; }

    handle_t await_suspend(std::coroutine_handle<> caller) noexcept {
        handle_.promise().continuation = caller;
        return handle_;   // symmetric transfer: start this task
    }

    T await_resume() {
        auto& result = handle_.promise().result;
        if (std::holds_alternative<std::exception_ptr>(result))
            std::rethrow_exception(std::get<2>(result));
        return std::move(std::get<1>(result));
    }

private:
    handle_t handle_;
};

Usage β€” coroutines can co_await other Tasks:

cpp
Task<int> fetch_value() {
    co_return 42;
}

Task<std::string> build_message() {
    int v = co_await fetch_value();   // suspends build_message, starts fetch_value
    co_return "The answer is " + std::to_string(v);
}

Symmetric transfer and stack overflow prevention

Without symmetric transfer, resuming a chain of coroutines creates a new stack frame for each resume() call. A chain of 10,000 coroutines would overflow the stack.

Symmetric transfer: when await_suspend returns a coroutine_handle, the current coroutine's frame is suspended first, then the returned handle is resumed β€” all within the same stack frame. This is a tail-call optimization for coroutines.

cpp
// Naive (stack grows with chain depth):
void await_suspend(std::coroutine_handle<> h) {
    h.resume();   // new stack frame for each resume
}

// Symmetric transfer (constant stack depth):
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
    return h;    // tail-resume: no new stack frame
}

Both the Task example above and the standard library's std::generator (C++23) use symmetric transfer.


Coroutine frame allocation

By default, coroutine frames are heap-allocated via operator new. The compiler may elide the allocation (HALO β€” Heap Allocation eLision Optimization) when:

  • The coroutine's lifetime is bounded by the caller's frame
  • The compiler can prove no allocation is needed

For guaranteed stack allocation in constrained environments (embedded, real-time), you can override frame allocation per promise_type:

cpp
struct promise_type {
    // Provide custom allocation:
    static void* operator new(std::size_t size, MyArena& arena) {
        return arena.allocate(size);
    }
    static void operator delete(void* ptr, std::size_t size) noexcept {
        // arena deallocation
    }
};

// Now:
Task<int> my_coro(MyArena& arena) { co_return 42; }
// Coroutine frame is allocated from 'arena'

Quick protocol reference

What compiler callsWhenYou return
promise_type() ctorFrame allocatedβ€”
get_return_object()Before bodyThe return type R
initial_suspend()Before body runssuspend_always (lazy) or suspend_never (eager)
yield_value(x)co_yield xAn awaitable (usually suspend_always)
return_value(v)co_return vvoid
return_void()co_return; or fall-offvoid
unhandled_exception()Uncaught exception in bodyvoid
final_suspend()After body completesAwaitable; return suspend_always to keep frame alive
await_ready()co_await exprbool β€” skip suspension?
await_suspend(handle)co_await expr, ready=falsevoid, bool, or handle (symmetric transfer)
await_resume()Coroutine resumesValue of the co_await expression

Coroutine frame layout in detail

The coroutine frame is a compiler-generated struct allocated on the heap, containing the promise object, all local variables that survive a suspension point, the current suspension index, and a pair of function pointers for resume and destroy. Local variables that do not survive any suspension point may be kept in ordinary stack slots and never placed in the frame at all β€” the compiler's liveness analysis determines this. The coroutine_handle<promise_type> is simply a pointer to the first word of this frame (which holds the resume function pointer), making it a single machine word.

cpp
// Conceptual layout the compiler creates for:
//   Task<int> compute(int x) { int tmp = x * 2; co_await something(); co_return tmp; }

struct __compute_frame {
    // Function pointers stored at fixed offsets (offset 0 and 8):
    void (*__resume_fn)(__compute_frame*);
    void (*__destroy_fn)(__compute_frame*);

    // The promise object:
    Task<int>::promise_type __promise;

    // Parameters captured into the frame:
    int x;

    // Locals that are live across a suspension point:
    int tmp;

    // The current suspension index:
    uint32_t __suspend_index;

    // (Padding/alignment inserted by the compiler as needed)
};

// coroutine_handle<promise_type>::from_promise(p) is:
//   reinterpret_cast<__compute_frame*>(
//       reinterpret_cast<char*>(&p) - offsetof(__compute_frame, __promise))
//
// coroutine_handle<>::resume() calls:
//   frame->__resume_fn(frame);

Understanding the layout matters for two reasons: it explains why coroutines can be expensive when local variables are large (they all live on the heap), and it explains why coroutine_handle is pointer-comparable and trivially copyable β€” it is nothing more than a typed pointer.


Heap allocation elision (HALO)

When the compiler can statically determine that a coroutine's lifetime is strictly nested within its caller's activation record, it is permitted to allocate the coroutine frame on the caller's stack instead of the heap β€” this is HALO (Heap Allocation eLision Optimization), and it is explicitly encouraged by the C++20 standard ([dcl.fct.def.coroutine]/9. In practice, HALO fires reliably when the coroutine handle does not escape the calling function: the coroutine is created, fully driven to completion, and destroyed, all within a single caller stack frame.

cpp
// HALO-eligible: the generator is fully consumed before compute() returns.
// The compiler can place the Generator frame in compute()'s own stack frame.
Task<int> compute() {
    auto gen = fibonacci();         // coroutine frame candidate for elision
    int result = 0;
    for (int n : gen | std::views::take(10))
        result += n;
    co_return result;               // gen destroyed here β€” frame never escapes
}

// HALO NOT eligible: handle escapes the function.
std::coroutine_handle<> g_handle;
void start_and_forget() {
    auto task = some_coro();
    g_handle = task.handle_;        // handle stored globally β†’ must heap-allocate
    task.release();                 // caller gives up ownership
}

You can confirm elision by examining the compiled output: with HALO the operator new call disappears from the coroutine's translation. Clang and GCC both implement HALO; the conditions are stricter in practice than the standard's minimum β€” both compilers require the coroutine to be inlined or fully visible at the call site.

To guarantee stack allocation in environments where heap allocation is forbidden (embedded, hard-real-time), provide a custom allocator via promise_type::operator new taking an std::pmr::memory_resource* or a custom arena parameter, and pass a stack-backed resource:

cpp
struct StackArena {
    std::array<std::byte, 4096> buf;
    std::size_t used = 0;
    void* allocate(std::size_t n) {
        void* p = buf.data() + used;
        used += (n + 15) & ~15u;   // 16-byte align
        return p;
    }
};

struct promise_type {
    static void* operator new(std::size_t sz, StackArena& arena, auto&&...) {
        return arena.allocate(sz);
    }
    static void operator delete(void*, std::size_t) noexcept {}
    // ... rest of promise ...
};

StackArena arena;
auto task = my_coro(arena /*, other args */);

Chained coroutines and symmetric transfer in depth

Without symmetric transfer, every .resume() call adds a stack frame, so a chain of N coroutines each awaiting the next would require O(N) stack space β€” a coroutine-based call graph of depth 10,000 would reliably overflow the stack. Symmetric transfer solves this by making await_suspend return a coroutine_handle<> instead of void: the runtime suspends the current coroutine, then immediately tail-calls into the returned handle, all within the same stack depth.

The mechanism is analogous to a tail call in functional languages. The key is that the current coroutine is fully suspended (its frame is up-to-date on the heap) before the next coroutine is resumed, so the stack never accumulates:

cpp
// WITHOUT symmetric transfer β€” stack grows linearly:
struct BadAwaiter {
    std::coroutine_handle<> next_;
    bool await_ready() noexcept { return false; }
    void await_suspend(std::coroutine_handle<> current) {
        next_.resume();   // new stack frame for 'next_'
                          // and 'current' is already on the stack above it
                          // β†’ stack depth = chain length
    }
    void await_resume() noexcept {}
};

// WITH symmetric transfer β€” constant stack depth:
struct GoodAwaiter {
    std::coroutine_handle<> next_;
    bool await_ready() noexcept { return false; }
    std::coroutine_handle<> await_suspend(std::coroutine_handle<> current) noexcept {
        return next_;     // suspend 'current', resume 'next_' as a tail call
                          // stack depth never increases
    }
    void await_resume() noexcept {}
};

The Task<T>::FinalAwaiter shown in the Task section earlier uses exactly this pattern: when a task completes, its FinalAwaiter::await_suspend returns h.promise().continuation, which resumes the awaiting coroutine without growing the stack. The edge case β€” no continuation β€” returns std::noop_coroutine(), which is a built-in handle that does nothing when resumed and prevents the runtime from resuming a null handle.

cpp
// Correct final awaiter for any async task:
struct FinalAwaiter {
    bool await_ready() noexcept { return false; }
    void await_resume() noexcept {}

    template<typename Promise>
    std::coroutine_handle<> await_suspend(
            std::coroutine_handle<Promise> h) noexcept {
        auto cont = h.promise().continuation;
        return cont ? cont : std::noop_coroutine();
        // noop_coroutine() is safe to resume β€” it immediately returns.
    }
};

Exception handling in coroutines

When an exception propagates out of a coroutine body uncaught, the runtime does not terminate the program β€” instead it calls promise_type::unhandled_exception(), which receives control with std::current_exception() live. The standard pattern is to capture the exception into the promise object with std::exception_ptr, then re-throw it when the caller later calls .get() or co_awaits the result. Forgetting to implement unhandled_exception() (or implementing it as a no-op) silently swallows exceptions.

cpp
template<typename T>
struct Task {
    struct promise_type {
        std::variant<std::monostate, T, std::exception_ptr> result_;

        void return_value(T v) {
            result_.template emplace<1>(std::move(v));
        }

        // CRITICAL: capture exception into promise storage
        void unhandled_exception() noexcept {
            result_.template emplace<2>(std::current_exception());
        }

        // ... initial_suspend, final_suspend, get_return_object ...
    };

    // Re-throw on .get():
    T get() {
        auto& r = handle_.promise().result_;
        if (auto* ex = std::get_if<2>(&r))
            std::rethrow_exception(*ex);
        return std::move(std::get<1>(r));
    }

    // Or re-throw inside await_resume() for co_await support:
    T await_resume() {
        return get();
    }
};

Several subtle issues arise around exceptions in coroutines:

cpp
// 1. Exceptions in initial_suspend are NOT caught by unhandled_exception.
//    They propagate directly to the caller of the coroutine function.
struct Promise {
    ThrowingAwaitable initial_suspend() { return {}; }  // if this throws β†’ direct throw
    void unhandled_exception() { /* NOT reached for initial_suspend throws */ }
};

// 2. Exceptions from co_yield (i.e. from yield_value()) propagate normally.
//    They ARE caught by unhandled_exception if they escape the body.

// 3. Exceptions in final_suspend's await_suspend() or await_resume()
//    result in std::terminate() β€” never throw from final_suspend awaiters.
struct SafeFinalAwaiter {
    bool await_ready() noexcept { return false; }
    std::coroutine_handle<> await_suspend(auto h) noexcept { // noexcept is REQUIRED
        return h.promise().continuation
            ? h.promise().continuation
            : std::noop_coroutine();
    }
    void await_resume() noexcept {}  // noexcept is REQUIRED
};

A complete, production-grade exception path therefore requires: unhandled_exception stores the exception_ptr; await_resume on the task re-throws it; and all awaiters in final_suspend are noexcept.