Coroutines in C++20: Asynchronous Programming

C++20 coroutines are functions that can suspend their execution at designated points and be resumed later, without blocking the calling thread. They use three new keywords: co_await (suspend until an asynchronous operation completes), co_yield (produce a value and suspend), and co_return (complete the coroutine with a value). A coroutine’s return type must provide a promise type that controls suspension, resumption, and value delivery. The standard library provides no ready-made coroutine types — you build them from the low-level protocol or use a library like cppcoro or C++23’s std::generator.

Introduction

Asynchronous programming is a hard problem. The traditional callback approach — “call this function when the operation completes” — produces deeply nested, hard-to-reason-about code known as “callback hell.” Futures and promises are better but still require explicit chaining. What developers really want is to write asynchronous code that reads like synchronous code, with the compiler handling the suspension and resumption machinery.

Coroutines deliver this. A coroutine is a function that can be paused at a co_await point, returning control to its caller while the awaited operation is in progress, then resumed exactly where it left off when the operation completes. To the programmer, the code reads sequentially. Under the hood, the compiler transforms it into a state machine that suspends and resumes.

C++20 provides the coroutine machinery — the keywords (co_await, co_yield, co_return), the promise protocol, and the awaitable protocol — but deliberately provides no concrete coroutine types in the standard library (except std::generator in C++23). This design gives maximum flexibility to library authors while keeping the language mechanism minimal and general.

This article teaches C++20 coroutines from the ground up. You will understand the coroutine lifecycle, implement the required promise type step-by-step, write generators that produce sequences lazily, build a simple async task type, and understand the awaitable protocol that makes co_await work with any asynchronous operation.

The Coroutine Lifecycle

Before writing coroutines, understanding their lifecycle is essential:

Caller calls coroutine function
    → Coroutine frame allocated on heap
    → Initial suspension (if any)
    → Coroutine body begins executing
         ↕ co_await: suspend here, return control to caller/resumer
         ↕ co_yield: produce value, suspend, wait for next()
    → co_return or falling off the end: coroutine completes
         → Coroutine frame destroyed

The critical insight: when a coroutine suspends at co_await, it does not block its thread. It simply saves its state (local variables, instruction pointer) into a heap-allocated coroutine frame and returns. The caller (or event loop) continues running. When the awaited operation completes, something resumes the coroutine — it picks up exactly where it left off.

// Three keywords that make a function a coroutine:

ReturnType myCoroutine() {
    // co_await: suspend until expr is ready
    auto result = co_await someAsyncOperation();

    // co_yield: produce a value and suspend
    co_yield computedValue;

    // co_return: complete the coroutine
    co_return finalValue;
}
// If a function contains any of these three, it IS a coroutine.
// Its return type must satisfy the coroutine promise protocol.

Building a Generator: co_yield Step by Step

The simplest coroutine type to understand is a generator — a coroutine that produces a sequence of values lazily using co_yield. Let’s build one from scratch, understanding each piece of the protocol.

#include <iostream>
#include <coroutine>
#include <optional>
#include <stdexcept>
using namespace std;

// A generator that produces values of type T lazily
template<typename T>
class Generator {
public:
    // ===== The Promise Type =====
    // Every coroutine return type must have a nested promise_type.
    // The compiler calls methods on this to control the coroutine.
    struct promise_type {
        optional<T> currentValue;  // Holds the most recently yielded value
        exception_ptr exception;   // Holds any exception thrown by coroutine

        // Called to create the Generator object returned to the caller
        Generator get_return_object() {
            return Generator{
                coroutine_handle<promise_type>::from_promise(*this)
            };
        }

        // Called at the very start: do we suspend immediately?
        // suspend_always: yes, don't run anything until first next() call
        suspend_always initial_suspend() noexcept { return {}; }

        // Called when the coroutine completes (falls off end or co_returns):
        // suspend_always: keep frame alive so we can detect completion
        suspend_always final_suspend() noexcept { return {}; }

        // Called for each co_yield value:
        suspend_always yield_value(T value) {
            currentValue = move(value);  // Store the yielded value
            return {};                   // Suspend (suspend_always)
        }

        // co_return with no value (void generators)
        void return_void() {}

        // If the coroutine throws an uncaught exception:
        void unhandled_exception() {
            exception = current_exception();  // Save it for rethrow
        }
    };

    // ===== The Generator Interface =====
    using Handle = coroutine_handle<promise_type>;

    // Constructor: takes ownership of the coroutine handle
    explicit Generator(Handle h) : handle_(h) {}

    // Destructor: MUST destroy the coroutine frame to avoid leak
    ~Generator() {
        if (handle_) handle_.destroy();
    }

    // Non-copyable (a coroutine frame has unique ownership)
    Generator(const Generator&)            = delete;
    Generator& operator=(const Generator&) = delete;

    // Movable
    Generator(Generator&& other) noexcept
        : handle_(exchange(other.handle_, nullptr)) {}

    // Check if the generator has more values
    bool hasNext() {
        if (!handle_ || handle_.done()) return false;

        handle_.resume();  // Run the coroutine until next co_yield or end

        if (handle_.promise().exception) {
            rethrow_exception(handle_.promise().exception);
        }
        return !handle_.done();
    }

    // Get the current value (call after hasNext() returned true)
    T value() const {
        return *handle_.promise().currentValue;
    }

    // ===== Range support: make Generator work in range-for loops =====
    struct Iterator {
        Handle handle;

        Iterator& operator++() {
            handle.resume();
            return *this;
        }
        T operator*() const {
            return *handle.promise().currentValue;
        }
        bool operator==(default_sentinel_t) const {
            return !handle || handle.done();
        }
    };

    Iterator begin() {
        handle_.resume();  // Advance to first yield
        return Iterator{handle_};
    }
    default_sentinel_t end() { return {}; }

private:
    Handle handle_;
};

// ===== Generator coroutines =====

// Infinite sequence of integers starting from start
Generator<int> count(int start = 0) {
    while (true) {
        co_yield start++;  // Yield start, then increment
    }
}

// Finite range [from, to)
Generator<int> range(int from, int to, int step = 1) {
    for (int i = from; i < to; i += step) {
        co_yield i;
    }
}  // Falling off the end completes the coroutine

// Fibonacci sequence (infinite)
Generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

// Yields only the prime numbers (infinite)
Generator<int> primes() {
    co_yield 2;
    // For each odd number, check if prime
    for (int n = 3; ; n += 2) {
        bool isPrime = true;
        for (int i = 3; i * i <= n; i += 2) {
            if (n % i == 0) { isPrime = false; break; }
        }
        if (isPrime) co_yield n;
    }
}

// Pipeline: transform a generator
Generator<int> transform(Generator<int>& source, auto fn) {
    for (int val : source) {
        co_yield fn(val);
    }
}

int main() {
    cout << "=== range(1, 11) ===" << endl;
    for (int n : range(1, 11)) {
        cout << n << " ";
    }
    cout << endl;

    cout << "\n=== range(0, 20, 3) ===" << endl;
    for (int n : range(0, 20, 3)) cout << n << " ";
    cout << endl;

    cout << "\n=== First 10 Fibonacci numbers ===" << endl;
    auto fib = fibonacci();
    int count_n = 0;
    for (long long n : fib) {
        cout << n << " ";
        if (++count_n == 10) break;
    }
    cout << endl;

    cout << "\n=== First 10 prime numbers ===" << endl;
    auto p = primes();
    int primeCount = 0;
    for (int n : p) {
        cout << n << " ";
        if (++primeCount == 10) break;
    }
    cout << endl;

    cout << "\n=== hasNext / value interface ===" << endl;
    auto r = range(5, 10);
    while (r.hasNext()) {
        cout << r.value() << " ";
    }
    cout << endl;

    cout << "\n=== Infinite counter, take 5 ===" << endl;
    auto counter = count(100);
    for (int i = 0; i < 5; i++) {
        counter.hasNext();
        cout << counter.value() << " ";
    }
    cout << endl;

    return 0;
}

Output:

=== range(1, 11) ===
1 2 3 4 5 6 7 8 9 10 

=== range(0, 20, 3) ===
0 3 6 9 12 15 18 

=== First 10 Fibonacci numbers ===
0 1 1 2 3 5 8 13 21 34 

=== First 10 prime numbers ===
2 3 5 7 11 13 17 19 23 29 

=== hasNext / value interface ===
5 6 7 8 9 

=== Infinite counter, take 5 ===
100 101 102 103 104

Step-by-step explanation:

  1. promise_type is the control center of a coroutine. The compiler looks for ReturnType::promise_type and calls its methods at specific points: get_return_object() (create the return value), initial_suspend() (suspend at start?), final_suspend() (suspend at end?), yield_value(v) (handle co_yield v), return_void() or return_value(v) (handle co_return), unhandled_exception() (handle uncaught exceptions).
  2. suspend_always and suspend_never are the two built-in awaitable types. Returning suspend_always{} from initial_suspend() means “suspend immediately at the start” — the coroutine body does not run until explicitly resumed. This is the right choice for generators: you do not want fibonacci() to run until you ask for the first value.
  3. coroutine_handle<promise_type> is the handle to the coroutine frame. It provides resume() (continue execution), done() (has the coroutine completed?), destroy() (free the frame), and promise() (access the promise object). The Generator owns this handle and must destroy it in the destructor to avoid memory leaks.
  4. The range-for support (begin()/end()) makes generators usable in for (int n : range(1, 11)). The begin() call immediately resumes the coroutine to the first co_yield, making the first value available. operator++ resumes to the next co_yield. Equality with default_sentinel_t checks handle.done().
  5. Infinite generators (fibonacci(), primes(), count()) are safe to use because they only run as many iterations as you request — each operator++ or hasNext() runs exactly one more co_yield and then suspends. No infinite loop occurs unless you iterate without stopping.

The Promise Protocol in Depth

Understanding the full promise protocol unlocks the ability to build any coroutine type:

#include <iostream>
#include <coroutine>
using namespace std;

// Trace every promise call to see the lifecycle
struct TracePromise {
    struct promise_type {
        promise_type() { cout << "  [promise] constructed\n"; }
        ~promise_type() { cout << "  [promise] destroyed\n"; }

        TracePromise get_return_object() {
            cout << "  [promise] get_return_object()\n";
            return TracePromise{
                coroutine_handle<promise_type>::from_promise(*this)
            };
        }

        suspend_always initial_suspend() {
            cout << "  [promise] initial_suspend() → suspend\n";
            return {};
        }

        suspend_always final_suspend() noexcept {
            cout << "  [promise] final_suspend() → suspend\n";
            return {};
        }

        void return_value(int v) {
            cout << "  [promise] return_value(" << v << ")\n";
            value = v;
        }

        void unhandled_exception() {
            cout << "  [promise] unhandled_exception()\n";
        }

        int value = 0;
    };

    coroutine_handle<promise_type> handle;

    explicit TracePromise(coroutine_handle<promise_type> h) : handle(h) {}

    ~TracePromise() {
        if (handle) handle.destroy();
    }

    void resume() {
        cout << "  [caller] resuming coroutine\n";
        handle.resume();
    }

    bool done() const { return handle.done(); }
    int  value() const { return handle.promise().value; }
};

TracePromise tracedCoroutine() {
    cout << "  [coroutine] starting body\n";
    int a = 10, b = 20;
    cout << "  [coroutine] computed a + b = " << (a + b) << "\n";
    co_return a + b;  // Makes this a coroutine; triggers return_value(30)
}

int main() {
    cout << "--- Calling tracedCoroutine() ---\n";
    auto coro = tracedCoroutine();

    cout << "\n--- After call, before first resume ---\n";
    cout << "  done: " << coro.done() << "\n";

    cout << "\n--- First resume (runs body until completion) ---\n";
    coro.resume();

    cout << "\n--- After body completes (at final_suspend) ---\n";
    cout << "  done: " << coro.done() << "\n";
    cout << "  value: " << coro.value() << "\n";

    cout << "\n--- TracePromise destructor will run ---\n";
    return 0;
}

Output:

--- Calling tracedCoroutine() ---
  [promise] constructed
  [promise] get_return_object()
  [promise] initial_suspend() → suspend

--- After call, before first resume ---
  done: 0

--- First resume (runs body until completion) ---
  [caller] resuming coroutine
  [coroutine] starting body
  [coroutine] computed a + b = 30
  [promise] return_value(30)
  [promise] final_suspend() → suspend

--- After body completes (at final_suspend) ---
  done: 1
  value: 30

--- TracePromise destructor will run ---
  [promise] destroyed

Step-by-step explanation:

  1. tracedCoroutine() is called normally. Inside the call: the promise is constructed, get_return_object() creates the TracePromise return value, initial_suspend() returns suspend_always — the coroutine suspends immediately. The caller receives the TracePromise object before the coroutine body runs a single line.
  2. After the call returns, done() is false — the coroutine is suspended at the initial suspension point, not yet complete.
  3. coro.resume() resumes the coroutine. It runs the body: computes a + b = 30, hits co_return 30, which calls promise_type::return_value(30) (storing 30), then hits final_suspend() which suspends again.
  4. After resume() returns, done() is true (suspended at final_suspend counts as done) and value() returns 30. The coroutine is not destroyed yet — final_suspend returning suspend_always keeps the frame alive so we can read the value.
  5. When TracePromise goes out of scope, its destructor calls handle.destroy(), which destroys the coroutine frame and the promise object. This is why the promise destructor prints last.

Building an Async Task Type

The most powerful coroutine use case is async programming. An Task<T> represents an asynchronous operation — it can be co_await-ed, suspending the awaiting coroutine until the task completes.

#include <iostream>
#include <coroutine>
#include <exception>
#include <functional>
#include <thread>
#include <chrono>
using namespace std;

// Forward declaration
template<typename T> class Task;

// ===== Task Promise Type =====
template<typename T>
struct TaskPromise {
    T result;
    exception_ptr exception;
    coroutine_handle<> continuation;  // Who to resume when we finish

    Task<T> get_return_object();

    suspend_always  initial_suspend() noexcept { return {}; }

    // On completion: resume the continuation (the awaiter), not suspend
    struct FinalAwaiter {
        bool await_ready() noexcept { return false; }

        // When we complete, resume whoever was waiting on us
        coroutine_handle<> await_suspend(
            coroutine_handle<TaskPromise<T>> h) noexcept {
            auto cont = h.promise().continuation;
            return cont ? cont : noop_coroutine();  // Resume continuation or do nothing
        }

        void await_resume() noexcept {}
    };

    FinalAwaiter final_suspend() noexcept { return {}; }

    void return_value(T value) { result = move(value); }
    void unhandled_exception() { exception = current_exception(); }
};

// ===== Task<T> =====
template<typename T>
class Task {
public:
    using promise_type = TaskPromise<T>;
    using Handle = coroutine_handle<promise_type>;

    explicit Task(Handle h) : handle_(h) {}
    ~Task() { if (handle_) handle_.destroy(); }

    Task(Task&& other) noexcept : handle_(exchange(other.handle_, nullptr)) {}
    Task(const Task&) = delete;

    // ===== Awaitable protocol: makes Task co_await-able =====
    // When another coroutine does: auto val = co_await myTask;
    // the compiler calls these three methods on the awaitable (our Task):

    // 1. Is the result already ready? (should we skip suspension?)
    bool await_ready() const noexcept {
        return handle_.done();  // If already complete, no need to suspend
    }

    // 2. If not ready: set ourselves as the continuation, start running
    coroutine_handle<> await_suspend(coroutine_handle<> caller) noexcept {
        handle_.promise().continuation = caller;  // Remember who to wake up
        return handle_;  // Symmetric transfer: start running this task
    }

    // 3. When resumed: return the result (or rethrow exception)
    T await_resume() {
        if (handle_.promise().exception)
            rethrow_exception(handle_.promise().exception);
        return move(handle_.promise().result);
    }

    // Synchronously run to completion (for entry point from main)
    T syncGet() {
        // Simple scheduler: spin until done
        while (!handle_.done()) {
            handle_.resume();
        }
        if (handle_.promise().exception)
            rethrow_exception(handle_.promise().exception);
        return move(handle_.promise().result);
    }

private:
    Handle handle_;
};

template<typename T>
Task<T> TaskPromise<T>::get_return_object() {
    return Task<T>{coroutine_handle<TaskPromise<T>>::from_promise(*this)};
}

// ===== Simulated async operations =====

// Simulate an async delay (in real code, this would integrate with an event loop)
struct SleepAwaitable {
    int ms;
    bool await_ready() const noexcept { return ms <= 0; }
    void await_suspend(coroutine_handle<> h) {
        // In real code: schedule h.resume() after ms milliseconds
        // Here: just sleep the thread for simplicity
        this_thread::sleep_for(chrono::milliseconds(ms));
        h.resume();  // Resume immediately after sleep
    }
    void await_resume() const noexcept {}
};

SleepAwaitable sleep(int ms) { return {ms}; }

// An async "database query"
Task<string> asyncQuery(const string& query, int delayMs) {
    cout << "  Starting query: " << query << "\n";
    co_await sleep(delayMs);  // Simulate async I/O
    cout << "  Query done: " << query << "\n";
    co_return "Result of [" + query + "]";
}

// An async operation that uses other async operations
Task<string> fetchUserProfile(int userId) {
    cout << "Fetching profile for user " << userId << "\n";

    // These run sequentially (each co_await suspends until complete)
    auto name    = co_await asyncQuery("SELECT name FROM users WHERE id=" + to_string(userId), 50);
    auto email   = co_await asyncQuery("SELECT email FROM users WHERE id=" + to_string(userId), 30);
    auto prefs   = co_await asyncQuery("SELECT prefs FROM user_prefs WHERE id=" + to_string(userId), 20);

    co_return name + " | " + email + " | " + prefs;
}

// Async computation
Task<int> asyncFactorial(int n) {
    if (n <= 1) co_return 1;
    int rest = co_await asyncFactorial(n - 1);
    co_return n * rest;
}

Task<double> asyncPipeline() {
    cout << "Starting pipeline\n";
    co_await sleep(10);
    double x = 3.14;
    co_await sleep(10);
    double y = x * x;
    co_await sleep(10);
    co_return y;
}

int main() {
    cout << "=== Sequential async queries ===\n";
    auto profileTask = fetchUserProfile(42);
    string profile = profileTask.syncGet();
    cout << "Profile: " << profile << "\n";

    cout << "\n=== Async factorial ===\n";
    auto factTask = asyncFactorial(6);
    int factResult = factTask.syncGet();
    cout << "6! = " << factResult << "\n";

    cout << "\n=== Async pipeline ===\n";
    auto pipeTask = asyncPipeline();
    double pipeResult = pipeTask.syncGet();
    cout << "Pi squared ≈ " << pipeResult << "\n";

    return 0;
}

Output:

=== Sequential async queries ===
Fetching profile for user 42
  Starting query: SELECT name FROM users WHERE id=42
  Query done: SELECT name FROM users WHERE id=42
  Starting query: SELECT email FROM users WHERE id=42
  Query done: SELECT email FROM users WHERE id=42
  Starting query: SELECT prefs FROM user_prefs WHERE id=42
  Query done: SELECT prefs FROM user_prefs WHERE id=42
Profile: Result of [SELECT name FROM users WHERE id=42] | Result of [SELECT email FROM users WHERE id=42] | Result of [SELECT prefs FROM user_prefs WHERE id=42]

=== Async factorial ===
6! = 720

=== Async pipeline ===
Starting pipeline
Pi squared ≈ 9.8596

Step-by-step explanation:

  1. The awaitable protocol is what makes co_await expr work with any type. Three methods: await_ready() (is the result already available — skip suspension?), await_suspend(handle) (called when we decide to suspend — take the caller’s handle, schedule resumption), await_resume() (called when resumed — return the result).
  2. FinalAwaiter in final_suspend() performs symmetric transfer: instead of returning from resume(), it directly transfers control to the continuation coroutine (the one that was awaiting us). This avoids O(n) stack growth for chains of co_await.
  3. fetchUserProfile reads sequentially: each co_await asyncQuery(...) suspends fetchUserProfile until the query completes, then resumes with the result. The code looks synchronous but is structured as a state machine by the compiler.
  4. SleepAwaitable shows the custom awaitable pattern: await_suspend takes the coroutine handle, does something asynchronous (in real code: register with an event loop), and at some point calls h.resume(). In our simplified example it sleeps the thread synchronously — in production code, it would schedule h.resume() on an event loop without blocking.
  5. syncGet() is the “driver” — it manually resumes the coroutine until completion. In real async code, this would be replaced by an event loop (Asio, libuv, etc.) that calls resume() when I/O events complete.

The Awaitable Protocol: Making Custom Types co_await-able

Any type can be co_await-ed by providing the three awaitable methods — either as members or through an operator co_await() conversion:

#include <iostream>
#include <coroutine>
#include <future>
#include <thread>
using namespace std;

// Make std::future<T> awaitable
template<typename T>
struct FutureAwaitable {
    future<T> fut;

    bool await_ready() {
        // Check if the future is already ready (non-blocking)
        return fut.wait_for(chrono::seconds(0)) == future_status::ready;
    }

    void await_suspend(coroutine_handle<> h) {
        // Launch a thread to wait for the future and resume the coroutine
        thread([h, fut = move(fut)]() mutable {
            fut.wait();   // Block THIS thread (not the coroutine's thread)
            h.resume();   // Wake up the coroutine on the waiting thread
        }).detach();
    }

    T await_resume() {
        return fut.get();
    }
};

// Awaitable for always-ready values (like co_await some_int)
template<typename T>
struct ImmediateAwaitable {
    T value;
    bool await_ready()  const noexcept { return true; }  // Always ready
    void await_suspend(coroutine_handle<>) noexcept {}    // Never suspends
    T    await_resume()       noexcept { return value; }
};

// Timer awaitable (conceptual: in real code uses event loop)
struct TimerAwaitable {
    chrono::milliseconds duration;

    bool await_ready() const noexcept {
        return duration.count() <= 0;
    }

    void await_suspend(coroutine_handle<> h) {
        thread([h, d = duration]() mutable {
            this_thread::sleep_for(d);
            h.resume();
        }).detach();
    }

    void await_resume() const noexcept {}
};

TimerAwaitable timer(int ms) {
    return {chrono::milliseconds(ms)};
}

// Demonstrating co_await with custom awaitables
// (Using our simple Task from before, simplified here)
struct SimpleTask {
    struct promise_type {
        SimpleTask get_return_object() {
            return SimpleTask{coroutine_handle<promise_type>::from_promise(*this)};
        }
        suspend_never  initial_suspend() noexcept { return {}; }
        suspend_always final_suspend()   noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { terminate(); }
    };

    coroutine_handle<promise_type> handle;
    explicit SimpleTask(coroutine_handle<promise_type> h) : handle(h) {}
    ~SimpleTask() { if (handle) handle.destroy(); }
    void wait() {
        while (!handle.done()) this_thread::yield();
    }
};

SimpleTask runWithFuture() {
    // Create a future that completes after 100ms
    auto fut = async(launch::async, []() -> string {
        this_thread::sleep_for(chrono::milliseconds(100));
        return "Future result!";
    });

    cout << "Before co_await future\n";
    string result = co_await FutureAwaitable<string>{move(fut)};
    cout << "After co_await future: " << result << "\n";
}

SimpleTask runWithTimer() {
    cout << "Before timer\n";
    co_await timer(50);
    cout << "After 50ms timer\n";
    co_await timer(50);
    cout << "After another 50ms timer\n";
}

SimpleTask runWithImmediate() {
    int x = co_await ImmediateAwaitable<int>{42};
    cout << "Immediate value: " << x << "\n";
}

int main() {
    cout << "=== co_await with std::future ===\n";
    auto t1 = runWithFuture();
    t1.wait();

    cout << "\n=== co_await with custom timer ===\n";
    auto t2 = runWithTimer();
    t2.wait();

    cout << "\n=== co_await immediate value ===\n";
    // runWithImmediate uses suspend_never, so it completes synchronously
    runWithImmediate();

    return 0;
}

Output:

=== co_await with std::future ===
Before co_await future
After co_await future: Future result!

=== co_await with custom timer ===
Before timer
After 50ms timer
After another 50ms timer

=== co_await immediate value ===
Immediate value: 42

Step-by-step explanation:

  1. FutureAwaitable wraps std::future<T> as an awaitable. await_ready() uses wait_for(0) to check non-blocking if the future is ready. await_suspend(h) launches a detached thread that blocks on the future and calls h.resume() when ready. await_resume() calls fut.get() to retrieve the result.
  2. ImmediateAwaitable has await_ready() = true — the coroutine never suspends. The compiler sees “already ready” and skips the suspension machinery entirely, calling await_resume() inline. This is zero-overhead for pre-computed values.
  3. TimerAwaitable follows the pattern for any time-based or event-based suspension: await_suspend registers the callback and returns; the suspended coroutine waits invisibly while the timer thread sleeps. When the timer fires, h.resume() wakes the coroutine.
  4. The await_suspend return type controls what happens after suspension: void (just suspend, resume will be called externally), bool (if false, don’t suspend after all), or coroutine_handle<> (symmetric transfer to another coroutine). The void form is used in FutureAwaitable and TimerAwaitable.
  5. suspend_never for initial_suspend() (as in SimpleTask) means the coroutine body starts running immediately when called. suspend_always means it suspends first. The choice depends on whether you want the caller to control when execution starts.

Coroutine States: How the Compiler Transforms Your Code

The compiler transforms a coroutine into a state machine. Understanding this helps debug coroutines:

// Original coroutine:
Generator<int> range(int from, int to) {
    for (int i = from; i < to; i++) {
        co_yield i;
    }
}

// Conceptually, the compiler generates something like this:
struct range_frame {
    // All local variables become frame members
    int from, to, i;
    promise_type promise;
    int state = 0;  // Which suspension point we're at

    void resume() {
        switch (state) {
            case 0:  goto state_0;
            case 1:  goto state_1;
        }
        state_0:
            // for (int i = from; ...)
            i = from;
            while (i < to) {
                // co_yield i  — suspend here
                promise.yield_value(i);
                state = 1;
                return;  // Suspend: return to caller
            state_1:
                ++i;
            }
            // Fall off end
            promise.return_void();
            // final_suspend...
    }
};

This is why local variables in coroutines have different lifetime than in regular functions — they live in the heap-allocated frame, not on the stack. It also explains why coroutines cannot use setjmp/longjmp and why coroutine frames may be larger than regular stack frames.

Coroutine Performance Considerations

Heap allocation:   One per coroutine (the frame) — may be elided by optimizer
Suspension cost:   Saving/restoring state — very fast (pointer store/load)
Resume cost:       Function call overhead — much cheaper than thread context switch
Frame size:        All locals + promise — can be large for complex coroutines

Compare to threads:
  Thread creation:    microseconds + 1-8 MB stack
  Coroutine creation: nanoseconds + ~100-500 bytes frame

Compare to callbacks:
  Callbacks:    heap-allocated closures, complex lifetime management
  Coroutines:   automatically managed frame, sequential code style

Heap allocation optimization:
  The compiler can often eliminate the frame allocation (HALO optimization)
  when the coroutine's lifetime is clearly scoped within the caller
  — making it as cheap as a regular function call.

Common Mistakes with Coroutines

Mistake 1: Not destroying the coroutine handle.

Task<int> compute() { co_return 42; }

// LEAK: the coroutine frame is never destroyed
auto h = coroutine_handle<TaskPromise<int>>::from_promise(...);
h.resume();
// No destroy() call — frame leaks!

// FIX: always call h.destroy() or use RAII wrapper (Task<T> handles this)

Mistake 2: Resuming a done coroutine.

auto task = compute();
task.syncGet();
task.syncGet();  // UB: resuming a done coroutine
// FIX: check handle.done() before resuming

Mistake 3: Capturing a coroutine local variable by address.

Task<int> bad() {
    int local = 42;
    co_await someAsync();  // Frame may move, pointer becomes invalid
    // local is fine to USE — it's in the frame
    // but: passing &local to something that outlives the co_await is dangerous
    co_return local;  // Fine: using local, not its address
}

Mistake 4: Using co_await in destructors.

struct Bad {
    ~Bad() {
        co_await something();  // COMPILE ERROR: destructors cannot be coroutines
    }
};
// FIX: Use synchronous cleanup or defer cleanup to the coroutine body

Mistake 5: Forgetting that co_await in a loop runs sequentially.

// This runs ALL queries sequentially — query 2 starts after query 1 finishes:
for (const auto& q : queries) {
    auto result = co_await asyncQuery(q);  // Sequential!
}

// To run in parallel, start all tasks first:
vector<Task<string>> tasks;
for (const auto& q : queries) {
    tasks.push_back(asyncQuery(q));  // Start all
}
for (auto& t : tasks) {
    auto result = co_await t;  // Now collect results
}

Coroutines Quick Reference

Keyword Where used What it does
co_yield expr In a coroutine Produce expr, suspend, resume later
co_return expr In a coroutine Complete with value expr
co_return In a void coroutine Complete with no value
co_await expr In a coroutine Suspend until expr is ready
promise_type Return type nested struct Controls coroutine lifecycle
get_return_object() In promise_type Create the return value
initial_suspend() In promise_type Suspend at start?
final_suspend() In promise_type Suspend at end?
yield_value(v) In promise_type Handle co_yield v
return_value(v) In promise_type Handle co_return v
return_void() In promise_type Handle co_return (no value)
unhandled_exception() In promise_type Handle uncaught exceptions
await_ready() In awaitable Skip suspension if true
await_suspend(h) In awaitable Called on suspension — schedule resume
await_resume() In awaitable Called on resume — return result
coroutine_handle<P> Anywhere Handle to a coroutine frame
suspend_always In promise Built-in: always suspend
suspend_never In promise Built-in: never suspend
noop_coroutine() In final_suspend No-op coroutine (for final transfer)

Conclusion

C++20 coroutines are a low-level, maximally flexible mechanism for suspension and resumption. They solve the fundamental problem of asynchronous programming — writing code that looks sequential while executing asynchronously — without callbacks, without thread-per-connection overhead, and without the complexity of hand-coded state machines.

The three keywords are simple. co_yield produces a value and suspends — the foundation of lazy generators. co_await suspends until an operation completes — the foundation of async tasks. co_return delivers the final result and completes the coroutine. Everything else — the promise type, the awaitable protocol, the handle lifecycle — is the machinery that makes these keywords work with any return type and any asynchronous operation.

Generators built on co_yield are immediately useful: infinite sequences, lazy pipelines, tree traversals, and any sequence that is expensive or impossible to materialize eagerly. They compose naturally — a generator can co_yield values drawn from another generator, building processing pipelines without intermediate storage.

Async tasks built on co_await transform callback-driven code into sequential-looking code. The same computation that would require nested callbacks or .then() chains reads as straightforward as synchronous code — each co_await is simply a “wait here until done.” When integrated with an event loop (Asio, libuv, platform I/O APIs), coroutines achieve millions of concurrent operations on a single thread, each running without a dedicated stack.

The deliberate absence of standard coroutine types (until std::generator in C++23) is a feature, not a gap. Library authors can build task types optimized for their event loop, generator types with custom memory management, and awaitable types for any platform API. The coroutine machinery is the primitive — expressive, zero-overhead, and general enough to build any higher-level abstraction on top.

Hot this week

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

Topics

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

Related Articles

Popular Categories