Coroutines under the hood

C++ · Expert · 25 min

Coroutines power async I/O frameworks and generators; using them safely requires knowing what the compiler generates.

A function becomes a coroutine as soon as its body uses co_await, co_yield or co_return. It can then suspend and later resume where it left off. C++20 coroutines are stackless: locals that live across a suspension are stored in a heap-allocated coroutine frame, which the compiler may elide when the coroutine's lifetime is nested in the caller.

The language supplies the mechanism; libraries supply the types. The return type (say Generator<int>) must name a nested `promise_type`, which customises behaviour:

  • get_return_object() builds what the caller receives, usually holding a std::coroutine_handle<promise_type>.
  • initial_suspend() decides whether the body starts eagerly or lazily; final_suspend() whether the frame stays alive after finishing (it must be noexcept).
  • yield_value(v), return_value(v)/return_void() and unhandled_exception() handle co_yield, co_return and exceptions.

A `std::coroutine_handle` is a non-owning pointer to the frame, with resume(), done(), destroy() and promise(). Someone must call destroy() exactly once, which is why wrapper types are move-only RAII classes.

co_await expr uses the awaiter protocol: await_ready() (skip suspending?), await_suspend(handle) (store the handle somewhere, such as an I/O completion queue, to resume later) and await_resume() (the result). std::suspend_always and std::suspend_never are trivial awaiters.

The biggest pitfall is lifetime. Reference parameters, and lambda captures for coroutine lambdas, are not copied into the frame, so a coroutine resumed after its caller's arguments died reads dangling memory. Take parameters by value.

C++23 adds std::generator<T> in <generator>, so you rarely write generators yourself; for async work use a library such as cppcoro, Asio or the C++26 std::execution model.

Example

#include <coroutine>
#include <iostream>
#include <utility>

template <typename T>
class Generator {
public:
    struct promise_type {
        T current{};
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }   // lazy start
        std::suspend_always final_suspend() noexcept { return {}; }     // keep frame for done()
        std::suspend_always yield_value(T v) noexcept { current = v; return {}; }
        void return_void() noexcept {}
        void unhandled_exception() { throw; }
    };
    explicit Generator(std::coroutine_handle<promise_type> h) : h_{h} {}
    Generator(Generator&& o) noexcept : h_{std::exchange(o.h_, {})} {}
    ~Generator() { if (h_) h_.destroy(); }                              // frees the frame
    bool next() { h_.resume(); return !h_.done(); }
    T value() const { return h_.promise().current; }
private:
    std::coroutine_handle<promise_type> h_;
};

Generator<int> fib(int n) {                     // co_yield makes this a coroutine
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) { co_yield a; b = a + b; a = b - a; }
}

int main() {
    auto g = fib(8);
    while (g.next()) std::cout << g.value() << ' ';   // 0 1 1 2 3 5 8 13
    std::cout << '\n';   // C++23: std::generator<int> from <generator> replaces all of this
}

Remember

  • Any of co_await/co_yield/co_return makes a coroutine with a heap frame
  • promise_type customises behaviour; coroutine_handle resumes and destroys
  • Take coroutine parameters by value; references may dangle across suspensions

Watch out for

  • Passing a const std::string& to a lazily started coroutine with a temporary argument — it dangles before the body runs.

Check yourself

  1. With `initial_suspend()` returning std::suspend_always, when does the coroutine body start running?
  2. Why does the Generator wrapper call h_.destroy() in its destructor?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/language/coroutines

Builds on

Unlocks

Nothing builds on this yet.