The C++ Textbook

Part 8 · Concurrency

Coroutines

Functions that suspend and resume, and what the compiler generates.

By the end of this chapter you can

  • Explain what co_await transforms a function into
  • Write a simple generator coroutine
  • Describe where the coroutine frame is allocated

Every function so far has run to completion once called. A coroutine can stop in the middle, hand control back, and be resumed later with all its local variables intact.

A function becomes a coroutine by containing co_await, co_yield or co_return. Nothing in its signature says so — the body decides, and the compiler rewrites the whole function around it.

A generator, by hand

Yielding values one at a time
#include <coroutine>
#include <cstdio>
#include <exception>
#include <utility>

template <class T>
class Generator {
public:
    // The compiler looks for this exact nested name.
    struct promise_type {
        T current;

        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }          // start suspended
        std::suspend_always final_suspend() noexcept { return {}; }   // stay alive at the end
        std::suspend_always yield_value(T value) {                    // what co_yield calls
            current = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    explicit Generator(std::coroutine_handle<promise_type> handle) : handle_(handle) {}
    ~Generator() { if (handle_) handle_.destroy(); }                  // frees the frame

    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    Generator(Generator&& other) noexcept : handle_(std::exchange(other.handle_, {})) {}

    bool next() { handle_.resume(); return !handle_.done(); }
    T value() const { return handle_.promise().current; }

private:
    std::coroutine_handle<promise_type> handle_;
};

Generator<int> counted(int limit) {
    for (int i = 0; i < limit; ++i) co_yield i;
}

Generator<long long> fibonacci() {          // no end at all
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        long long next = a + b;
        a = b;
        b = next;
    }
}

int main() {
    Generator<int> counter = counted(4);
    std::printf("counted:");
    while (counter.next()) std::printf(" %d", counter.value());
    std::printf("\n");

    Generator<long long> fibs = fibonacci();
    std::printf("first ten fibonacci:");
    for (int i = 0; i < 10; ++i) { fibs.next(); std::printf(" %lld", fibs.value()); }
    std::printf("\n");
}

fibonacci contains while (true) and no exit. It is not an infinite loop because it suspends at every co_yield and only continues when someone asks for the next value. Laziness for free — the same property Chapter 7.4 measured for range views, obtained from the language rather than from a library.

What the compiler generated

counted looks like a function returning Generator<int>. It is not: no return statement appears in it, and co_yield is not a keyword any ordinary function may contain. What the compiler actually built is roughly this:

  1. Find the promise type. Look at the declared return type Generator<int> for a nested promise_type. Everything else follows from it.
  2. Allocate a frame. A block of storage holding the parameters, every local that lives across a suspension, the promise object, and an index recording where in the function to resume.
  3. Construct the promise in the frame, and call get_return_object() — that is what the caller receives.
  4. co_await promise.initial_suspend(), before running any of your code. suspend_always here is what makes the generator lazy.
  5. Run the body until a suspension point. Each co_yield v becomes co_await promise.yield_value(v).
  6. co_await promise.final_suspend() at the end. Returning suspend_always keeps the frame alive so the caller can ask done(); it also means you are responsible for destroying it, which is what the Generator destructor does.

A std::coroutine_handle is the address of that frame with a type attached. It is a raw handle in the same sense a pointer is: resume() on a finished coroutine, or on a destroyed one, is undefined behaviour, and forgetting destroy() leaks the frame. Wrapping it in an RAII type — which is all Generator is — is not optional.

What co_await actually does

co_await expr is three function calls on the awaited object.

The three hooks, traced
#include <coroutine>
#include <cstdio>
#include <exception>
#include <utility>

struct Task {
    struct promise_type {
        Task get_return_object() { return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; }
        std::suspend_never initial_suspend() { return {}; }      // start running immediately
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    explicit Task(std::coroutine_handle<promise_type> handle) : handle_(handle) {}
    ~Task() { if (handle_) handle_.destroy(); }
    Task(Task&& other) noexcept : handle_(std::exchange(other.handle_, {})) {}

    bool done() const { return handle_.done(); }
    void resume() { handle_.resume(); }

private:
    std::coroutine_handle<promise_type> handle_;
};

// An awaitable that has its answer already.
struct Ready {
    int value;
    bool await_ready() const noexcept { std::printf("  await_ready -> true\n"); return true; }
    void await_suspend(std::coroutine_handle<>) const noexcept {}
    int await_resume() const noexcept { std::printf("  await_resume -> %d\n", value); return value; }
};

// One that suspends, handing the handle to whoever will resume it later.
struct Pause {
    bool await_ready() const noexcept { std::printf("  await_ready -> false\n"); return false; }
    void await_suspend(std::coroutine_handle<>) const noexcept { std::printf("  await_suspend: parked\n"); }
    void await_resume() const noexcept { std::printf("  await_resume: carrying on\n"); }
};

Task demo() {
    std::printf("coroutine started\n");
    int a = co_await Ready{40};
    co_await Pause{};
    int b = co_await Ready{2};
    std::printf("coroutine finished with %d\n", a + b);
}

int main() {
    Task task = demo();
    std::printf("back in main, done = %d\n", int(task.done()));
    task.resume();
    std::printf("back in main, done = %d\n", int(task.done()));
}

The protocol:

  • await_ready() — is the result already available? true means do not suspend at all, which is how an awaitable avoids paying for suspension when it does not need to.
  • await_suspend(handle) — called only if await_ready() said no. The coroutine is now suspended and you have been handed its handle. Store it somewhere that will resume() it: an event loop, an I/O completion callback, another thread. Returning void means “stay suspended and return to the caller”.
  • await_resume() — called when execution continues, and its return value is what co_await expr evaluates to.

That is the whole of the asynchrony story. Every async framework built on coroutines is await_suspend handing the handle to something that will call resume() later.

Notice the ordering in the output: main regains control at the Pause, prints its line, and only then does the second half of the coroutine run. One thread, two interleaved call stacks.

Where the frame lives

The frame is heap-allocated, and you can measure it.

One allocation, sized by the locals
#include <coroutine>
#include <cstdio>
#include <cstdlib>
#include <exception>
#include <new>
#include <utility>

int allocations = 0;
std::size_t bytes = 0;

void* operator new(std::size_t n) {
    ++allocations;
    bytes += n;
    void* p = std::malloc(n ? n : 1);
    if (!p) throw std::bad_alloc{};
    return p;
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

struct Counter {
    struct promise_type {
        int current = 0;
        Counter get_return_object() { return Counter{std::coroutine_handle<promise_type>::from_promise(*this)}; }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(int v) { current = v; return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    explicit Counter(std::coroutine_handle<promise_type> h) : handle_(h) {}
    ~Counter() { if (handle_) handle_.destroy(); }
    Counter(Counter&& o) noexcept : handle_(std::exchange(o.handle_, {})) {}
    bool next() { handle_.resume(); return !handle_.done(); }
private:
    std::coroutine_handle<promise_type> handle_;
};

Counter small() { co_yield 1; co_yield 2; }

Counter large() {
    char scratch[4096]{};          // a local that lives across a suspension
    scratch[0] = 1;
    for (int i = 0; i < 3; ++i) co_yield i + scratch[0];
}

int main() {
    allocations = 0; bytes = 0;
    { Counter c = small(); while (c.next()) {} }
    std::printf("small coroutine: %d allocation, %zu bytes\n", allocations, bytes);

    allocations = 0; bytes = 0;
    { Counter c = large(); while (c.next()) {} }
    std::printf("large coroutine: %d allocation, %zu bytes\n", allocations, bytes);
}

One allocation each: 40 bytes for the small one, 4,144 for the one with a 4 KB local. The frame holds exactly what has to survive a suspension — parameters, the promise, the resume index, and the locals whose lifetimes cross a co_yield. A local used entirely between two suspension points can live on the ordinary stack.

The allocation goes through operator new — the global one, or a member operator new on the promise type if you provide one, which is the hook for a pool allocator.

The parameter trap

Coroutine parameters are copied into the frame. A reference parameter copies the reference, not the referent — and the frame outlives the call expression.

A reference parameter that outlives its argument
#include <coroutine>
#include <cstdio>
#include <exception>
#include <string>
#include <utility>

struct Letters {
    struct promise_type {
        char current = 0;
        Letters get_return_object() { return Letters{std::coroutine_handle<promise_type>::from_promise(*this)}; }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(char c) { current = c; return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    explicit Letters(std::coroutine_handle<promise_type> h) : handle_(h) {}
    ~Letters() { if (handle_) handle_.destroy(); }
    Letters(Letters&& o) noexcept : handle_(std::exchange(o.handle_, {})) {}
    bool next() { handle_.resume(); return !handle_.done(); }
    char value() const { return handle_.promise().current; }
private:
    std::coroutine_handle<promise_type> handle_;
};

Letters letters(const std::string& text) {      // reference stored in the frame
    for (char c : text) co_yield c;
}

int main() {
    Letters l = letters(std::string("abc"));    // the temporary dies at this semicolon
    while (l.next()) std::printf("%c ", l.value());
    std::printf("\n");
}

ERROR: AddressSanitizer: stack-use-after-scope

The temporary std::string is destroyed at the end of the statement that created the coroutine — before a single character has been yielded, because the generator starts suspended. The frame’s reference then points at nothing.

The fix is to take the parameter by value, so the frame owns a copy:

Letters letters(std::string text) { for (char c : text) co_yield c; }

This is the single most common coroutine bug, and it is worth being precise about why it is worse than the equivalent bug with a lambda. Chapter 3.5’s rule — a reference parameter must outlive the call — is not enough here, because the “call” is over almost immediately and the coroutine runs afterwards. Every compiler’s advice on this is the same: coroutines take parameters by value, and pay the copy.

Where this stands

Coroutines are the most powerful and least finished feature in C++20.

What they are genuinely good at:

  • Lazy sequences. The fibonacci generator above is difficult to express as cleanly any other way — an equivalent iterator has to store the state machine by hand, which is exactly what the compiler did for you.
  • Asynchronous I/O without callbacks. auto data = co_await socket.read(); reads like blocking code, suspends without occupying a thread, and composes. This is what Asio, libunifex and cppcoro exist for.
  • State machines, where the suspension points are the states.

What to be aware of:

  • The C++20 library support is essentially nothing. std::generator is C++23; a task type is not in the standard at all.
  • Every coroutine is a heap allocation unless HALO applies.
  • Debugging is harder: a stack trace shows the resume point, not the logical chain of awaits.
  • The lifetime rules are new and the compiler checks few of them.

Check yourself

Practice