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 β€” 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.


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

What's Next