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

Master C++20 Coroutines: Suspendable Functions from the Ground Up

Build intuition for C++20 coroutines by implementing a generator and async task, understanding promise_type, co_yield, and co_await end-to-end.

By the end of this page, you will understand what makes a function a coroutine, implement a reusable Generator<T> type from scratch, write a minimal Task<T> for async pipelines, recognize the three lifecycle control points the compiler calls on your behalf, and avoid the three most common lifetime traps.

What and Why

A regular function runs to completion and then returns. A coroutine is a function that can suspend mid-execution, yield control back to its caller, and later resume exactly where it left off β€” retaining all its local variables across suspensions.

This matters because many real-world problems are naturally expressed as sequences that produce values lazily (infinite ranges, database cursors, network streams) or as async workflows that wait on I/O without blocking a thread. Before C++20, both patterns required manual state machines, callbacks, or heavyweight threads. Coroutines give you the same result with straight-line, readable code.

C++20 introduces coroutines as a language mechanism, not a library: the compiler transforms any function that contains co_yield, co_await, or co_return into a state machine. The standard library deliberately ships no concrete coroutine types β€” you or a library author supplies those by implementing a promise_type.


Step by Step

Step 1 β€” The three keywords

KeywordWhat it does
co_yield exprSuspend and produce a value to the caller
co_await exprSuspend until some awaitable signals completion
co_return exprFinish the coroutine, optionally returning a value

Using any one of them turns the surrounding function into a coroutine. The compiler then requires that the return type of that function expose a nested promise_type.

Step 2 β€” A minimal generator

promise_type is the hook the compiler uses to ask: "How do I create the return object? What do I do when the coroutine starts, yields, or finishes?"

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

// A simple owning wrapper around a coroutine handle.
struct Generator {
    struct promise_type {
        int current_value{};

        Generator get_return_object() {
            return Generator{Handle::from_promise(*this)};
        }
        // Suspend immediately so the body doesn't run until the caller asks.
        std::suspend_always initial_suspend() noexcept { return {}; }
        // Stay suspended after the final statement so we can query done().
        std::suspend_always final_suspend() noexcept { return {}; }

        std::suspend_always yield_value(int v) noexcept {
            current_value = v;
            return {};
        }
        void return_void() noexcept {}
        void unhandled_exception() noexcept { std::terminate(); }
    };

    using Handle = std::coroutine_handle<promise_type>;

    explicit Generator(Handle h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }

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

    // Returns false when the coroutine body has finished.
    bool next() { handle.resume(); return !handle.done(); }
    int  value() const { return handle.promise().current_value; }

private:
    Handle handle;
};

Generator range(int first, int last) {
    for (int i = first; i <= last; ++i)
        co_yield i;          // suspends here each iteration
}

int main() {
    auto gen = range(1, 5);
    while (gen.next())
        std::cout << gen.value() << '\n';   // prints 1 2 3 4 5
}

Compile with: g++ -std=c++20 -o gen gen.cpp

Notice that range looks like an ordinary loop. The compiler secretly allocates a heap frame to hold i, first, last, and a resume point. Each call to handle.resume() continues from the last suspension point.

Step 3 β€” Make it generic

Hardcoding int is unnecessary. Templatizing Generator only requires lifting current_value to type T:

cpp
#include <coroutine>
#include <optional>
#include <utility>

template<typename T>
struct Generator {
    struct promise_type {
        std::optional<T> current_value;

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

        template<std::convertible_to<T> U>
        std::suspend_always yield_value(U&& v) {
            current_value = std::forward<U>(v);
            return {};
        }
        void return_void() noexcept {}
        void unhandled_exception() noexcept { std::terminate(); }
    };

    using Handle = std::coroutine_handle<promise_type>;

    explicit Generator(Handle h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }
    Generator(const Generator&) = delete;
    Generator(Generator&& o) noexcept : handle(std::exchange(o.handle, {})) {}

    bool next() { handle.resume(); return !handle.done(); }
    T&   value() { return *handle.promise().current_value; }

private:
    Handle handle;
};

Generator<std::string> greetings() {
    co_yield "hello";
    co_yield "world";
}

int main() {
    auto g = greetings();
    while (g.next())
        puts(g.value().c_str());
}

Common Patterns

Pattern 1 β€” Infinite lazy sequence

Because the coroutine suspends between yields, it is safe to loop forever:

cpp
#include <coroutine>
#include <cstdint>

// Re-use the Generator<T> template from Step 3 above.

Generator<uint64_t> fibonacci() {
    uint64_t a = 0, b = 1;
    for (;;) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

// Caller decides how many terms it wants β€” the sequence never allocates ahead.

Pattern 2 β€” Pipeline composition

Coroutines compose naturally: one generator feeds another.

cpp
Generator<int> take(Generator<int> src, int n) {
    for (int i = 0; i < n && src.next(); ++i)
        co_yield src.value();
}

Pattern 3 β€” std::generator<T> from the standard library (C++23)

C++23 ships std::generator<T> in <generator>, so you no longer need to roll your own Generator type for simple use cases. It models ranges::view and ranges::input_range, which means you can pass it directly to range algorithms and pipe it into std::views pipelines.

cpp
#include <generator>    // C++23
#include <iostream>
#include <ranges>

std::generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        int next = a + b;
        a = b;
        b = next;
    }
}

int main() {
    // std::generator is a range β€” pipe it straight into views
    for (int n : fibonacci() | std::views::take(8)) {
        std::cout << n << ' ';   // 0 1 1 2 3 5 8 13
    }
}

std::generator also supports recursive delegation via co_yield std::ranges::elements_of(range), which flattens a sub-generator into the parent's output without manual forwarding loops β€” useful for tree traversal:

cpp
std::generator<int> inorder(const Tree* node) {
    if (!node) co_return;
    co_yield std::ranges::elements_of(inorder(node->left));   // delegate
    co_yield node->value;
    co_yield std::ranges::elements_of(inorder(node->right));  // delegate
}

Use std::generator when you want a lazily-produced sequence and your compiler supports C++23. Keep a hand-rolled Generator<T> only when you need co_await support, a custom allocator strategy, or C++20 compatibility.

Pattern 4 β€” Minimal async Task

co_await suspends on any type that satisfies the awaitable protocol (await_ready, await_suspend, await_resume). A trivial always-ready awaitable makes the mechanics visible:

cpp
#include <coroutine>
#include <iostream>

struct ImmediateAwaitable {
    bool await_ready() const noexcept { return true; }   // never actually suspends
    void await_suspend(std::coroutine_handle<>) noexcept {}
    int  await_resume() const noexcept { return 42; }
};

struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() noexcept { return {}; }
        std::suspend_never final_suspend()   noexcept { return {}; }
        void return_void()         noexcept {}
        void unhandled_exception() noexcept { std::terminate(); }
    };
};

Task run() {
    int result = co_await ImmediateAwaitable{};
    std::cout << "Got: " << result << '\n';
}

int main() { run(); }

Real async frameworks (ASIO, liburing wrappers) supply awaitables that schedule the resume on I/O completion β€” the coroutine body stays linear while the machinery underneath is non-blocking.

Exception Handling in Coroutines

When an exception escapes the body of a coroutine the compiler does not let it propagate directly to the caller. Instead the generated code catches it and calls promise.unhandled_exception(). What happens next is entirely up to your promise_type.

The minimal generator above uses:

cpp
void unhandled_exception() noexcept { std::terminate(); }

That is safe for generators that are not expected to throw, but it is a silent trap if they do. For a Task<T> type that must propagate errors to an await-ing coroutine, the standard pattern is to capture the live exception into a std::exception_ptr and rethrow it when the caller collects the result:

cpp
#include <coroutine>
#include <exception>
#include <stdexcept>

template<typename T>
struct Task {
    struct promise_type {
        T result{};
        std::exception_ptr error;   // stores any uncaught exception

        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend()   noexcept { return {}; }

        void return_value(T v) { result = std::move(v); }

        // Called by the compiler inside a catch(...) block when an exception
        // escapes the coroutine body.  std::current_exception() is valid here.
        void unhandled_exception() {
            error = std::current_exception();   // capture; don't rethrow yet
        }
    };

    using Handle = std::coroutine_handle<promise_type>;
    explicit Task(Handle h) : handle(h) {}
    ~Task() { if (handle) handle.destroy(); }

    // Drive the coroutine to completion and return its result (or rethrow).
    T get() {
        handle.resume();
        if (handle.promise().error)
            std::rethrow_exception(handle.promise().error);  // rethrow here
        return handle.promise().result;
    }

private:
    Handle handle;
};

Task<int> divide(int a, int b) {
    if (b == 0) throw std::domain_error("division by zero");
    co_return a / b;
}

int main() {
    try {
        auto t = divide(10, 0);
        int v = t.get();   // rethrows domain_error
    } catch (const std::exception& e) {
        // caught here, not inside the coroutine
    }
}

Three key rules derived from cppreference and confirmed by compiler behaviour (GCC 11+, Clang 17+, MSVC):

  1. unhandled_exception() is mandatory β€” omitting it is ill-formed; the entire coroutine body is wrapped in an implicit try/catch(...) that calls it.
  2. Calling std::terminate() (or any noexcept no-op) is valid but silently swallows errors β€” only use it when exceptions truly cannot happen.
  3. Capturing with std::current_exception() and rethrowing later (as shown above) is the portable idiom for async Task<T> types; it preserves the original exception type and stack context.

Symmetric Transfer and Stack Safety

Consider a Task<T> coroutine that awaits another Task<T> in a loop:

cpp
task completes_synchronously() { co_return; }

task loop(int count) {
    for (int i = 0; i < count; ++i)
        co_await completes_synchronously();   // danger zone
}

Without symmetric transfer, each co_await resumes completes_synchronously by calling coroutine_handle::resume() from inside await_suspend. When the inner coroutine finishes it calls resume() again to wake loop. Each pair of calls nests another frame on the call stack, so with count = 1'000'000 you get a stack overflow.

Symmetric transfer (introduced by P0913 for C++20) eliminates this by allowing await_suspend to return a std::coroutine_handle<> instead of calling resume() directly. The compiler lowers this to a guaranteed tail call β€” the current frame is popped before the next coroutine begins, so no stack accumulates regardless of chain length.

The signature that triggers symmetric transfer:

cpp
std::coroutine_handle<> await_suspend(std::coroutine_handle<> caller) noexcept;
//                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  returning a handle  suspends *this* coroutine and resumes the returned one
//  in a single O(1) tail-call β€” no extra stack frame

A correct lazy Task that uses symmetric transfer in its final_awaiter:

cpp
#include <coroutine>
#include <utility>

struct Task {
    struct promise_type {
        std::coroutine_handle<> continuation; // set by the awaiting coroutine

        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }

        // final_awaiter uses symmetric transfer to resume the continuation
        struct FinalAwaiter {
            bool await_ready() noexcept { return false; }
            // Return the continuation handle β€” compiler turns this into a tail call
            std::coroutine_handle<> await_suspend(
                std::coroutine_handle<promise_type> h) noexcept {
                auto cont = h.promise().continuation;
                return cont ? cont : std::noop_coroutine();
            }
            void await_resume() noexcept {}
        };
        FinalAwaiter final_suspend() noexcept { return {}; }

        void return_void() noexcept {}
        void unhandled_exception() noexcept { std::terminate(); }
    };

    using Handle = std::coroutine_handle<promise_type>;
    explicit Task(Handle h) : handle(h) {}
    ~Task() { if (handle) handle.destroy(); }
    Task(Task&& o) noexcept : handle(std::exchange(o.handle, {})) {}

    // operator co_await stores the caller handle then resumes this task
    auto operator co_await() && noexcept {
        struct Awaiter {
            Handle coro;
            bool await_ready() noexcept { return false; }
            // Symmetric transfer: store caller, resume inner task as tail call
            std::coroutine_handle<> await_suspend(
                std::coroutine_handle<> caller) noexcept {
                coro.promise().continuation = caller;
                return coro;   // tail-call into the task body
            }
            void await_resume() noexcept {}
        };
        return Awaiter{handle};
    }

private:
    Handle handle;
};

std::noop_coroutine() is the escape hatch: returning it from await_suspend means "suspend and return to whoever called resume()" without resuming any further coroutine.

When to apply this pattern: any time your Task-like type allows one coroutine to directly resume another β€” especially when loops or recursion are involved. Library types like asio::awaitable already apply symmetric transfer internally, so you only need to implement it yourself when building a custom coroutine framework.

Pattern 5 β€” Real Async I/O with Asio

The patterns above show the machinery; Asio (Boost.Asio or standalone Asio) shows what it looks like to use that machinery from the outside. Asio supplies its own awaitable types, so from your coroutine you just co_await socket operations directly.

The three Asio pieces you need:

APIRole
asio::awaitable<T>Return type for a C++20 coroutine that Asio can schedule
asio::co_spawn(executor, coro, token)Launch an awaitable<T> on an executor
asio::detachedCompletion token that ignores the result (fire-and-forget)

Minimal async TCP echo server (from the Boost.Asio docs):

cpp
#include <boost/asio.hpp>
#include <cstdio>

namespace asio = boost::asio;
using tcp = asio::ip::tcp;

// Each accepted connection runs in this coroutine.
// The return type asio::awaitable<void> marks it as an Asio C++20 coroutine.
asio::awaitable<void> echo(tcp::socket socket) {
    try {
        char data[1024];
        for (;;) {
            // co_await suspends until bytes arrive; returns byte count
            std::size_t n = co_await socket.async_read_some(
                asio::buffer(data));                     // default: deferred token

            // co_await suspends until all bytes are written
            co_await asio::async_write(socket,
                asio::buffer(data, n));
        }
    } catch (const std::exception& e) {
        // I/O errors (EOF, reset) surface as exceptions via system_error
        std::printf("echo: %s\n", e.what());
    }
}

asio::awaitable<void> listener(asio::io_context& ctx) {
    tcp::acceptor acceptor(ctx, {tcp::v4(), 55555});
    for (;;) {
        tcp::socket socket = co_await acceptor.async_accept();
        // Spawn each client on the same executor; detached = fire-and-forget
        asio::co_spawn(ctx, echo(std::move(socket)), asio::detached);
    }
}

int main() {
    asio::io_context ctx;
    asio::co_spawn(ctx, listener(ctx), asio::detached);
    ctx.run();   // blocks until all work is done
}

What this demonstrates about the coroutine model:

  • async_read_some and async_write return awaitables; co_await on them suspends the coroutine and returns the thread to the event loop. When I/O completes, Asio calls resume() on the coroutine handle β€” the same mechanism as ImmediateAwaitable from Pattern 4, but driven by the OS event loop rather than synchronous code.
  • Errors from failed operations are converted from boost::system::error_code to std::system_error and thrown at the co_await expression, so you handle them with normal try/catch rather than manually checking error codes.
  • co_spawn is the bridge between synchronous main() and the coroutine world; it registers the coroutine with the executor and starts it on the next run() iteration.

Compile with: g++ -std=c++20 -lboost_system -o echo echo.cpp (or link standalone Asio with -DASIO_STANDALONE).


What Can Go Wrong

Mistake 1 β€” Forgetting initial_suspend

If initial_suspend returns std::suspend_never, the coroutine body starts running the moment the caller constructs the return object β€” before the caller can attach any context. For generators, almost always return std::suspend_always.

Mistake 2 β€” Destroying a non-final-suspended handle

cpp
// WRONG β€” destroys the handle before the coroutine has finished
Generator<int> gen = range(1, 10);
gen.handle.destroy();   // if the body is mid-execution, UB

Always let the Generator destructor handle lifetime, and only destroy after done() returns true or after you've stopped iterating.

Mistake 3 β€” Capturing a reference to a local across a suspension point

cpp
Generator<int*> bad() {
    int local = 99;
    co_yield &local;   // caller resumes later β€” local is still alive here
    // but if the Generator is moved or the frame is somehow invalidated,
    // the pointer the caller holds becomes dangling
}

Prefer yielding values, not pointers or references into the coroutine frame. If you must yield a view, document the lifetime contract explicitly.


Quick Reference

ConceptKey type / keywordNotes
Make a function a coroutineco_yield / co_await / co_returnAny one is sufficient
Connect return type to machinerypromise_type nested structMust be public
Control start behaviourinitial_suspend()suspend_always = lazy
Control end behaviourfinal_suspend()suspend_always = safe to query done()
Produce a value and pauseco_yield exprCalls promise.yield_value(expr)
Await an async resultco_await awaitableCalls await_ready/suspend/resume
Finish with a valueco_return exprCalls promise.return_value(expr)
Resume / check statushandle.resume() / handle.done()Always check done() before resuming
Clean up the framehandle.destroy()Call exactly once, after coroutine is done or abandoned
Standard generator (C++23)std::generator<T> in <generator>Models input_range; use with range pipelines
Delegate sub-sequenceco_yield std::ranges::elements_of(rng)Flattens a sub-generator inline (C++23)

What's Next