C++ Coroutines (C++20)


  • Description: A note on C++20 coroutines — co_await, co_yield, co_return, std::generator (C++23), custom awaitables, common coroutine types, and when to use them
  • My Notion Note ID: K2A-B1-26
  • Created: 2021-08-01
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. What's a Coroutine?

  • Coroutine = function that can be suspended and resumed.
  • Looks like an ordinary call to the caller; body can yield control midway, save state, resume later (possibly different thread, possibly post-async-op).
  • Function is a coroutine iff body uses co_await, co_yield, or co_return.
#include <generator>     // C++23

std::generator<int> count_to(int n) {
    for (int i = 0; i < n; ++i) {
        co_yield i;            // suspend, hand i back to caller, resume later
    }
}

for (int x : count_to(5)) {
    std::cout << x << " ";     // 0 1 2 3 4
}

2. The Three Operators: co_await, co_yield, co_return

Operator Meaning
co_await expr Suspend the coroutine until expr is "ready"; produce a value when resumed.
co_yield val Suspend the coroutine, giving val to the caller. Equivalent to co_await yield_value(val).
co_return val End the coroutine, optionally producing a final value.
  • Any combination usable in one coroutine body.
task<int> compute() {
    int a = co_await fetch_a();   // suspend, resume when fetch_a is done
    int b = co_await fetch_b();
    co_return a + b;
}

3. std::generator (C++23)

  • std::generator<T> — standard coroutine type producing a sequence of Ts. Is a std::ranges::range → works with ranges.
#include <generator>
#include <ranges>

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

for (int x : fibs() | std::views::take(10)) std::cout << x << " ";
// 0 1 1 2 3 5 8 13 21 34
  • Easiest coroutine type — synchronous, pull-based (caller drives), no thread-safety concerns.

4. Coroutine Anatomy: Promise and Awaitable

  • Library code, not language magic. 2 types do the work:
  1. Promise type — defined inside return type. Controls what coroutine produces + lifetime (e.g., std::generator<T>::promise_type).
  2. Awaitable — what co_await expr operates on. Has await_ready, await_suspend, await_resume.
  • Rarely write either yourself unless implementing a custom coroutine type. Use std::generator (C++23) or cppcoro / asio coroutines for async I/O.

Minimal awaitable:

struct Suspend {
    bool await_ready()    const noexcept { return false; }      // suspend?
    void await_suspend(std::coroutine_handle<>) const noexcept {}  // what to do on suspend
    void await_resume()   const noexcept {}                      // value on resume
};
  • stdlib provides std::suspend_always and std::suspend_never for trivial cases.

5. Common Coroutine Types

  • C++20 = language facility only. stdlib doesn't ship a full set of coroutine types yet.
  1. std::generator<T> (C++23) — synchronous pull generator. stdlib.
  2. task<T> — async return value. Not standard yet (expected C++26). Use cppcoro, folly, or asio.
  3. lazy<T>, shared_task<T>, async streams — third-party.
  • Until C++26: third-party coroutine lib (or framework-provided — Boost.Asio's awaitable<T>, Qt's QCoro).

6. When to Use Coroutines

  1. Generators / lazy sequences — cleaner than custom iterators or callback pipelines.
  2. Async I/O — straight-line code, event loop behind the scenes.
  3. State machines — each co_await = state transition; language handles bookkeeping.
  4. Pull-based parsing — stream tokens/AST nodes without materializing whole input.

When not:

  1. Hot loops — suspension overhead matters.
  2. Already-simple sync code — coroutine adds return type, promise type, lifetime concerns.
  3. Tight memory budgets — each frame heap-allocated by default (compiler may elide, not always).

7. Performance Considerations

  1. Heap allocation per coroutine — compiler may elide (HALO — Heap Allocation eLision Optimization), but only if it can prove frame doesn't escape. Don't rely on it for hot paths.
  2. State-machine overhead — each suspension = switch dispatch. Negligible for coarse-grained async, noticeable in tight loops.
  3. Inlining — harder for compiler to inline through.
  4. Symmetric transfer — coroutine awaiting another switches directly without unwinding stack. Efficient.
  • For most async workloads: net win in readability, comparable perf to hand-written callback chains.
  • For generators: similar to well-written iterator.