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?

A coroutine is a function whose execution can be suspended and resumed. From the caller's view, it looks like an ordinary function call, but the function body can give up control midway, save its state, and pick up again later — possibly on a different thread, possibly after an asynchronous operation finishes.

A function is a coroutine if its body uses any of the three coroutine keywords: 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.

A single coroutine body may use any combination of these.

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> is the standard library's coroutine type for producing a sequence of Ts. It's a std::ranges::range, so it works with the ranges library.

#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

generator is the easiest coroutine type to work with: synchronous, pull-based (caller drives iteration), no thread-safety concerns.


4. Coroutine Anatomy: Promise and Awaitable

The coroutine machinery is library code, not language magic. Two types do the heavy lifting:

  1. The promise type — defined inside the return type, controls what the coroutine "produces" and how its lifetime is managed (e.g., std::generator<T>::promise_type).
  2. The awaitable — what co_await expr operates on. Must have await_ready, await_suspend, await_resume methods.

You rarely need to write either yourself unless you're implementing a custom coroutine type. Use std::generator (C++23), or higher-level libraries like cppcoro / asio coroutines for async I/O.

A 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
};

The standard library provides std::suspend_always and std::suspend_never for the two trivial cases.


5. Common Coroutine Types

C++20 only provided the language facility — the standard library doesn't yet ship a complete set of coroutine types. As of the latest standards:

  1. std::generator<T> (C++23) — synchronous pull generator. Standard library.
  2. task<T> — async return value. No standard version yet (expected in C++26). Use cppcoro, folly, or asio in the meantime.
  3. lazy<T>, shared_task<T>, async streams — third-party libraries.

Until C++26 lands, you'll usually be using a third-party coroutine library for async work, or a framework-provided coroutine (Boost.Asio's awaitable<T>, Qt's QCoro, etc.).


6. When to Use Coroutines

  1. Generators / lazy sequences. Cleaner than custom iterators or callback-based pipelines.
  2. Async I/O. Reads as straight-line code while running on event loops behind the scenes.
  3. State machines. Each co_await is a state transition; the language handles the bookkeeping.
  4. Pull-based parsing. Stream tokens or AST nodes one at a time without materializing the whole input.

When not to use coroutines:

  1. Hot loops where the suspension overhead matters.
  2. Code where the synchronous version is already simple — a coroutine adds a return type, a promise type, and lifetime considerations.
  3. Tight memory budgets — each coroutine frame is heap-allocated by default (compilers can elide this, but not always).

7. Performance Considerations

  1. Heap allocation per coroutine. The compiler may elide it (HALO — Heap Allocation eLision Optimization), but only if it can prove the frame doesn't escape. Don't rely on it for hot paths.
  2. State-machine overhead. Each suspension point is a switch dispatch. Negligible for coarse-grained async work, noticeable in tight loops.
  3. Inlining. Coroutines are harder for the compiler to inline through.
  4. Symmetric transfer. When one coroutine awaits another, the runtime can switch directly without rewinding the stack — efficient.

For most async workloads, coroutines are a net win in readability with comparable performance to hand-written callback chains. For generators, performance is similar to a well-written iterator.