본문으로 건너뛰기
Previous
Next
C++20 Coroutines — Complete Guide

C++20 Coroutines — Complete Guide

C++20 Coroutines — Complete Guide

이 글의 핵심

C++20 coroutines: escape callback hell with co_await and co_yield. When async code gets messy, coroutines suspend and resume so you can write sequential-looking code.

Introduction: “I want a function that yields one value at a time”

Suspend and resume

Sometimes you need a function that does not compute everything at once, but produces the next value on each call. Or you want to wait for I/O without blocking a thread, and continue later from the same point. A coroutine (a function whose execution can be suspended and later resumed from the same place) is language support for that “suspend / resume” model.

Goals:

  • co_yield: yield one value and suspend (generators)
  • co_await: suspend until an asynchronous operation completes
  • co_return: finish the coroutine and optionally return a value
  • Understand which return type drives the coroutine machinery

co_yield powers generators (lazy sequences that yield one value per step); co_await models asynchronous waits. Both help you write sequential-looking code instead of callback pyramids. In C++20 you often design the framework (promise_type, awaitables) yourself, which is a learning cost—but once it clicks, structuring async and streaming code becomes much clearer.

Not threads: coroutines are not OS threads. A thread is a unit of scheduling; a coroutine is a flow that pauses and resumes inside a thread. Many coroutines can exist without running truly in parallel, and you can design them so locks are unnecessary.

Build: use g++ -std=c++20 or clang++ -std=c++20 for C++20 coroutines.

After reading this post you will:

  • Know basic terms: promise, awaiter, handle
  • Follow co_yield flows that produce values one by one
  • Understand the “wait until complete” pattern with co_await
  • Reason about promise_type and coroutine handle lifetime
  • Recognize common mistakes and production patterns

Production perspective: this article draws on real issues and fixes from large C++ codebases. It includes practical pitfalls and debugging tips that short tutorials often skip.

Table of contents

  1. Async code gets complicated
  2. What is a coroutine?
  3. co_yield and generators
  4. co_await and suspension
  5. co_return in detail
  6. End-to-end coroutine implementations
  7. promise_type explained
  8. Coroutine handles and lifetime
  9. Common mistakes
  10. Best practices
  11. Performance comparison
  12. Production patterns
  13. Practical caveats

1. Async code gets complicated

Callback hell

For async work—network calls, file I/O, timers—the classic style is callbacks. When a step finishes, a callback runs, which starts the next step… Deep nesting makes the code hard to read.

Example fetchUserData implementation:

// ❌ Callback hell: poor readability, messy error handling
void fetchUserData(const std::string& userId,
    std::function<void(User)> onUser,
    std::function<void(Error)> onError)
{
    api.getUser(userId, [onUser, onError](User user) {
        api.getOrders(user.id, [onUser, onError, user](Orders orders) {
            api.getDetails(orders[0].id, [onUser, user, orders](Details details) {
                // three levels deep... what if it goes deeper?
                onUser(mergeUserData(user, orders, details));
            }, onError);
        }, onError);
    }, onError);
}

Problems:

  • Readability: deeper nesting is harder to follow
  • Errors: every stage must thread onError through
  • Control flow: you want a linear story, but the structure is non-linear
  • Cancel / timeout: hard to stop in the middle

Coroutines as a fix

With co_await you write like synchronous code while execution remains asynchronous.

// ✅ Coroutine: linear code, simpler errors
Task<UserData> fetchUserData(const std::string& userId) {
    User user = co_await api.getUserAsync(userId);
    Orders orders = co_await api.getOrdersAsync(user.id);
    Details details = co_await api.getDetailsAsync(orders[0].id);
    co_return mergeUserData(user, orders, details);
}

Benefits:

  • Readability: top-to-bottom flow
  • Errors: one try/catch can cover the whole chain
  • Scaling: adding steps does not explode nesting

More scenarios

Scenario 2: large file streaming

Loading a multi‑GB log into memory at once causes OOM. Callback style becomes “read chunk → callback → parse → request next chunk,” again nested. With a generator and co_yield, you can write “yield one line at a time” as straightforward code.

// ❌ Callbacks: openFile → readChunk → parseLines → readChunk nesting
void readLogFile(const std::string& path,
    std::function<void(std::string)> onLine,
    std::function<void(Error)> onError) {
    openFile(path, [onLine, onError](File f) {
        readChunk(f, [onLine, onError, f](Chunk c) {
            for (auto& line : parseLines(c)) onLine(line);
            readChunk(f, /* ... more nesting ... */);
        }, onError);
    }, onError);
}

// ✅ Generator: sequential, one line at a time
Generator<std::string> readLogLines(const std::string& path) {
    std::ifstream f(path);
    std::string line;
    while (std::getline(f, line)) co_yield line;
}

Scenario 3: game AI state machines

An NPC might go “idle → pathfind → move → attack → idle,” with waits for “next frame,” “animation done,” or “target acquired.” Callbacks scatter transition logic; with co_await you can express “wait for next frame / event” and keep the state machine in one linear function.

// ❌ Callbacks: findPath( { moveAlong( { playAttack({...}); }); });
// ✅ Coroutines: co_await findPathAsync; co_await moveAlongAsync; co_await playAttackAsync;

Scenario 4: WebSocket pipelines

Connect → authenticate → message loop: each step is async. Nested callbacks scatter “disconnect cleanup” and “timeout” across levels. Coroutines let you centralize cleanup and exceptions in one try/catch.

Scenario 5: real-time sensor pipelines

Stream in sensor data and chain filter → transform → aggregate. A generator pipeline (filtermapbatch) stays lazy and memory‑efficient.


2. What is a coroutine?

Suspend and resume

A coroutine is a function that can stop in the middle and resume later from the same point.

  • Ordinary function: call → run → return → done
  • Coroutine: call → (suspend) → resume → (suspend) → … → return

Mermaid sketch:

flowchart LR
    subgraph normal[Ordinary function]
        N1[call] --> N2[run] --> N3[return] --> N4[done]
    end
    subgraph coro[Coroutine]
        C1[call] --> C2[run] --> C3[suspend]
        C3 --> C4[resume] --> C5[run] --> C6[return]
    end

Think of an ordinary function as a movie that plays straight through; a coroutine is one you can pause and continue from the same scene later. That fits generators (yield one value and stop) and async flows (wait for I/O, then continue).

Keywords

  • co_yield expr
    Treat expr as the “current value,” yield control to the caller, and resume on the next request from after this co_yield.
  • co_await expr
    expr must be an awaitable; suspend until it completes, then resume.
  • co_return
    End the coroutine and optionally pass a final value.

Functions that are coroutines

If the body contains any of co_await, co_yield, or co_return, that function is a coroutine.

count(n) is a generator that yields 0 … n−1 one at a time. Each co_yield i passes i to the caller and suspends there. The next request resumes after that co_yield and yields i+1. You never materialize a million integers in a vector up front—you compute on demand, saving memory and upfront work. Generator<int> is a user/library type with a promise_type; the next article (#23-2) walks through a concrete implementation.

#include <coroutine>

Generator<int> count(int n) {
    for (int i = 0; i < n; ++i) {
        co_yield i;  // yield i and suspend
    }
}

3. co_yield and generators

Conceptual example

For a generator gen built from count(3), each next step yields 0, 1, 2, then reaches the end. Values are not precomputed, so even count(1’000’000) only needs memory for the current value, and the consumer can stop early—lazy consumption. Generator<T> is implemented by defining promise_type. See the next post (#23-2) for a full generator. The standard adds std::generator in C++23; on C++20-only projects you implement a small generator type or use a library (e.g. cppcoro).

Behavior summary

  • co_yield value
    • Calls the promise’s yield_value(value)
    • Stores the current value and passes it to the caller
    • If that returns suspend_always, the coroutine suspends

What happens under co_yield

// co_yield value is roughly lowered to:
// promise.yield_value(value);
// → if yield_value returns suspend_always, suspend
// → when the caller calls handle.resume(), execution continues after yield_value

4. co_await and suspension

Awaitable

In co_await expr, expr must be awaitable, usually providing:

  • await_ready()
    If already complete, return trueno suspension
  • await_suspend(coroutine_handle)
    Called on suspend; register the handle with a thread / event loop so something calls resume when work completes
  • await_resume()
    Value or result after resume

Flow: for co_await expr, the compiler calls await_ready() first. If true, it skips suspension and uses await_resume() for the result. If false, it calls await_suspend(handle) to schedule “resume this handle later,” then suspends. When resumed, await_resume()’s return value becomes the result of the co_await expression.

sequenceDiagram
    participant C as Coroutine
    participant A as Awaitable
    participant E as Event loop

    C->>A: await_ready()
    alt already complete
        A-->>C: true
        C->>A: await_resume()
        A-->>C: result
    else need to wait
        A-->>C: false
        C->>A: await_suspend(handle)
        A->>E: register handle
        Note over C: suspended
        E->>C: resume()
        C->>A: await_resume()
        A-->>C: result
    end

Minimal sketch

struct Task {
    struct promise_type { /* ... */ };
    // ...
};

Task asyncRead() {
    int value = co_await someAsyncOperation();  // suspend until complete
    co_return value;
}

In practice you use std::suspend_always / std::suspend_never from <coroutine>, or library Task / promise types. Difference:

#include <coroutine>

// suspend_always: await_ready() is false → always suspend first, resume later
// suspend_never:  await_ready() is true  → do not suspend, continue immediately

// Example: promise initial_suspend()
std::suspend_always initial_suspend() { return {}; }  // wait until first co_yield/co_await
std::suspend_never  initial_suspend() { return {}; }  // run body right after entry

For generators, initial_suspend() with suspend_always means “do nothing until the first value is requested”; with suspend_never, the coroutine runs as soon as it is invoked.


5. co_return in detail

Two forms

  • co_return expr;
    Calls promise_type::return_value(expr) for coroutines that return a value.
  • co_return;
    Calls promise_type::return_void() for void coroutines.

Do not provide both return_value and return_void for the same promise. Pick one that matches the return type.

// Coroutine with a return value
Task<int> compute() {
    int result = 42;
    co_return result;  // calls return_value(result)
}

// Void coroutine (e.g. generator)
Generator<int> count(int n) {
    for (int i = 0; i < n; ++i)
        co_yield i;
    co_return;  // calls return_void() (sometimes optional)
}

co_return and final_suspend

After co_return (or falling off the end where allowed), final_suspend() runs. If it returns suspend_always, the coroutine may suspend in a completed state so the caller can inspect handle.done() or read the final result. If it returns suspend_never, the frame may be torn down immediately.


6. End-to-end coroutine implementations

Generator (co_yield)

#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>

template <typename T>
class Generator {
public:
    struct promise_type {
        T current_value;

        std::suspend_always yield_value(T value) {
            current_value = std::move(value);
            return {};
        }

        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::rethrow_exception(std::current_exception()); }

        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
    };

    struct iterator {
        std::coroutine_handle<promise_type> handle;
        bool done;

        iterator(std::coroutine_handle<promise_type> h, bool d) : handle(h), done(d) {}

        T operator*() const { return handle.promise().current_value; }
        iterator& operator++() {
            handle.resume();
            done = handle.done();
            return *this;
        }
        bool operator!=(const iterator& other) const {
            return done != other.done;
        }
    };

    explicit Generator(std::coroutine_handle<promise_type> h) : handle_(h) {}
    ~Generator() { if (handle_) handle_.destroy(); }

    Generator(Generator const&) = delete;
    Generator& operator=(Generator const&) = delete;
    Generator(Generator&& other) noexcept : handle_(other.handle_) { other.handle_ = nullptr; }
    Generator& operator=(Generator&& other) noexcept {
        if (this != &other) {
            if (handle_) handle_.destroy();
            handle_ = other.handle_;
            other.handle_ = nullptr;
        }
        return *this;
    }

    iterator begin() {
        handle_.resume();
        return iterator{handle_, handle_.done()};
    }
    iterator end() { return iterator{handle_, true}; }

private:
    std::coroutine_handle<promise_type> handle_;
};

Generator<int> count(int n) {
    for (int i = 0; i < n; ++i) {
        co_yield i;
    }
}

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

Async Task (co_await, co_return)

#include <coroutine>
#include <exception>
#include <future>
#include <iostream>

template <typename T>
class Task {
public:
    struct promise_type {
        T value;
        std::exception_ptr exception;

        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }

        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        void return_value(T v) { value = std::move(v); }
        void unhandled_exception() { exception = std::current_exception(); }
    };

    explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
    ~Task() { if (handle_) handle_.destroy(); }

    T get() {
        handle_.resume();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return std::move(handle_.promise().value);
    }

private:
    std::coroutine_handle<promise_type> handle_;
};

// Trivial awaitable that completes immediately
struct ImmediateAwaitable {
    int value;
    bool await_ready() const { return true; }
    void await_suspend(std::coroutine_handle<>) {}
    int await_resume() const { return value; }
};

Task<int> asyncCompute() {
    int a = co_await ImmediateAwaitable{10};
    int b = co_await ImmediateAwaitable{20};
    co_return a + b;  // 30
}

int main() {
    auto task = asyncCompute();
    std::cout << task.get() << "\n";  // 30
}

Production-style Task with error propagation

In real code, exceptions are often stored in the promise and rethrown when the caller runs get().

// Task with errors — store in unhandled_exception
// #include <optional> as needed
template <typename T>
class Task {
public:
    struct promise_type {
        std::optional<T> value;
        std::exception_ptr exception;

        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }

        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        void return_value(T v) { value = std::move(v); }
        void unhandled_exception() { exception = std::current_exception(); }
    };

    T get() {
        while (!handle_.done()) handle_.resume();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return std::move(*handle_.promise().value);
    }
    // ...(constructors, destructor, move only)
};

Deferred completion awaitable (timeout sketch)

If await_ready() returns false, the coroutine truly suspends. An event loop or thread pool later calls resume.

// Awaitable that completes later — event-loop integration sketch
struct DelayedAwaitable {
    int result;
    std::coroutine_handle<>* stored_handle = nullptr;

    bool await_ready() const { return false; }  // always suspend first
    void await_suspend(std::coroutine_handle<> h) {
        stored_handle = &h;  // register h with the event loop
    }
    int await_resume() const { return result; }
};

// Usage: when work completes, the loop calls stored_handle->resume()

Runnable example: resume from another thread

Below, resume() runs on another thread after a short sleep. The handle is captured by value in await_suspend so its lifetime is clear.

struct DelayedAwaitable {
    int result;
    bool await_ready() const { return false; }
    void await_suspend(std::coroutine_handle<> h) {
        std::thread([h]() {
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
            h.resume();
        }).detach();
    }
    int await_resume() const { return result; }
};

Task<int> asyncAdd() {
    int a = co_await DelayedAwaitable{10};
    int b = co_await DelayedAwaitable{20};
    co_return a + b;
}

Build: g++ -std=c++20 -pthread -o coro_demo coro_demo.cpp

File line generator (complete)

#include <coroutine>
#include <fstream>
#include <string>

Generator<std::string> readLines(const std::string& path) {
    std::ifstream f(path);
    if (!f) throw std::runtime_error("Failed to open file: " + path);
    std::string line;
    while (std::getline(f, line)) {
        co_yield line;  // one line at a time, then suspend
    }
}

// Usage
for (const auto& line : readLines("config.txt")) {
    if (line.starts_with("#")) continue;
    process(line);
}

7. promise_type in depth

Connecting return type and machinery

If the coroutine’s return type is R, the compiler looks for R::promise_type. That type controls lifetime and behavior.

flowchart TD
    subgraph lifecycle[Coroutine lifetime]
        A[call coroutine] --> B[create promise]
        B --> C[get_return_object]
        C --> D[initial_suspend]
        D --> E{suspend?}
        E -->|suspend_always| F[return to caller]
        E -->|suspend_never| G[run body]
        F --> H[resume]
        H --> G
        G --> I[co_yield/co_await/co_return]
        I --> J[final_suspend]
        J --> K[coroutine ends]
    end

Required / expected members

MethodRole
get_return_object()Build the object returned to the caller—often wrapping coroutine_handle::from_promise(*this).
initial_suspend()Suspend immediately on entry (suspend_always vs suspend_never). Generators often use suspend_always so the first value waits for the first resume.
final_suspend()Whether to suspend after completion. suspend_always lets the caller observe handle.done() safely.
yield_value(value)Implements co_yield.
return_value(v)Implements co_return with a value.
return_void()Implements co_return with no value.
unhandled_exception()Called on exceptions; usually store std::current_exception() for later rethrow or log.

Design patterns

  • Generator: yield_value + return_void, initial_suspend = suspend_always
  • Async Task: return_value + awaitable support, finalize via final_suspend
  • Lazy values: initial_suspend = suspend_always, compute on first resume

Minimal promise using co_await and co_return

#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>

// Awaitable: type you can co_await
struct SleepAwaitable {
    int ms;
    bool await_ready() const { return ms <= 0; }
    void await_suspend(std::coroutine_handle<>) const {
        // In real code: register with timer / event loop
    }
    void await_resume() const {}
};

// Task: co_await + co_return
template <typename T>
struct Task {
    struct promise_type {
        std::optional<T> value;
        std::exception_ptr exception;

        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }

        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        void return_value(T v) { value = std::move(v); }
        void unhandled_exception() { exception = std::current_exception(); }
    };

    std::coroutine_handle<promise_type> handle_;

    explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
    ~Task() { if (handle_) handle_.destroy(); }

    T get() {
        while (!handle_.done()) handle_.resume();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return std::move(*handle_.promise().value);
    }
};

// Example: co_await + co_return
Task<int> computeAsync() {
    co_await SleepAwaitable{0};  // await_ready() == true → no real wait
    int a = 10, b = 20;
    co_return a + b;  // return_value(30)
}

int main() {
    auto task = computeAsync();
    std::cout << task.get() << "\n";  // 30
}

Takeaway: get_return_object() builds the task object; initial_suspend / final_suspend choose suspension points; return_value handles co_return; unhandled_exception stores errors for get() to rethrow.


8. Coroutine handles and lifetime

What is coroutine_handle?

std::coroutine_handle<Promise> refers to the coroutine frame. Use resume() to continue, done() to test completion, and destroy() to free the frame.

std::coroutine_handle<promise_type> handle;

handle.resume();   // resume coroutine
bool finished = handle.done();  // completed?
handle.destroy();  // destroy frame (lifetime)

Lifetime rules

While suspended, locals live in the coroutine frame. The handle points at that frame, so the frame is valid only while the handle (or a Generator / Task owning it) stays alive.

Generator<int> makeGen() {
    int local = 42;  // stored in coroutine frame
    co_yield local;  // suspend — local remains in frame
    co_yield local + 1;  // still valid on resume
}

// ✅ OK: frame lives while gen lives
auto gen = makeGen();
for (int x : gen) { /* ... */ }

// ❌ Risk: after gen is destroyed, handle.destroy() ran
// Keeping a copied handle after gen is gone → dangling
flowchart LR
    subgraph valid[Valid lifetime]
        V1[Create Generator/Task] --> V2[allocate frame]
        V2 --> V3[resume / iterate]
        V3 --> V4[destroy on object destruction]
    end
    subgraph invalid[Unsafe]
        I1[copy handle only] --> I2[destroy owning object]
        I2 --> I3[dangling handle]
    end

9. Common mistakes

1. Dangling references

Cause: the coroutine stores or returns references to locals or parameters that die before the next resume.

// ❌ Risk: s may refer to a temporary std::string from the caller
Generator<std::string_view> splitBad(std::string_view s) {
    // If s referred to a temporary, it may dangle after the call returns
    co_yield s.substr(0, 1);  // possible UB
}

// ✅ Safer: own the data by value
Generator<std::string> splitGood(std::string s) {
    co_yield s.substr(0, 1);
}

2. Handle lifetime bugs

Cause: copy a coroutine_handle, destroy the owning Generator/Task, then resume the copy—UB.

// ❌ Risk: use handle after owning object is gone
{
    auto gen = count(5);
    auto h = gen.handle();  // if exposed by API
}  // gen destructor → destroy()
h.resume();  // UB: frame freed

// ✅ Keep the Generator alive for the whole use
auto gen = count(5);
for (int x : gen) { /* ... */ }

3. Missing / conflicting promise methods

Cause: defining both return_value and return_void, or omitting required hooks.

// ❌ Compile error: cannot have both
struct promise_type {
    void return_value(int) {}
    void return_void() {}
};

// ✅ One or the other, matching the coroutine’s contract
struct promise_type {
    void return_value(int v) { value = v; }  // e.g. Task<int>
    // no return_void()
};

4. final_suspend and suspend_never

If final_suspend() returns suspend_never, the frame may be destroyed immediately after co_return. If your caller has not read the result from the promise yet, that read can be invalid. Many designs use suspend_always and an extra resume() before destroy().

// final_suspend() = suspend_never
// → frame may be freed right after co_return
// → reading promise.value can be invalid

// final_suspend() = suspend_always
// → caller resumes, observes done(), reads value, then destroy()

5. Thread safety

await_suspend may schedule resume on another thread. Never let two threads resume the same coroutine concurrently.

// ❌ Danger: concurrent get()/resume
auto task = asyncCompute();
std::thread t1([&]{ task.get(); });
std::thread t2([&]{ task.get(); });  // data race

// ✅ Single-threaded resume, or proper synchronization

6. Swallowing exceptions

If unhandled_exception() does nothing, errors can disappear.

// ❌ Bad: drop the exception
void unhandled_exception() {
}

// ✅ Store and rethrow from get()
void unhandled_exception() {
    exception = std::current_exception();
}
// get(): if (exception) std::rethrow_exception(exception);

7. Invalidating iterators

Moving gen inside a range-for invalidates iterators.

// ❌ Danger: move gen inside the loop
for (int x : gen) {
    auto other = std::move(gen);  // invalidates — UB on next ++
}

// ✅ Move before the loop
auto other = std::move(gen);
for (int x : other) { /* ... */ }

8. “Recursive” coroutines and the stack

Coroutines do not share one stack like nested function calls—each has its own heap frame, so deep chains avoid classic stack overflow. But synchronous resume() nesting still grows the call stack (A resumes B resumes A…), so unbounded synchronous recursion can still blow the stack.

9. promise / return type mismatch

A Task<int> coroutine needs return_value(int), not only return_void, and vice versa for void generators.

// ❌ Error: Task<int> needs return_value
Task<int> foo() {
    co_return;  // tries return_void
}

// ✅ Define return_value(int)
void return_value(int v) { value = v; }

10. Performance comparison

Coroutines vs callbacks vs threads

ApproachMemoryContext switchReadabilityBest for
CallbacksLowNoneLowTiny async snippets
CoroutinesMedium (frame)None (cooperative)HighI/O-bound work, generators
ThreadsHigh (stacks)Yes (OS)MediumCPU-bound parallelism

Generator vs vector

Scenario: integers 0 … 1_000_000

ApproachMemory (rough)Startup costWhen it wins
vector<int>~4 MiBfull allocationYou need the whole range
Generator<int>~hundreds of bytesalmost nonePartial consumption, lazy
// Vector: allocate all million
std::vector<int> vec(1000000);
std::iota(vec.begin(), vec.end(), 0);
for (int i = 0; i < 10; ++i)  // only need 10 but hold 1M
    use(vec[i]);

// Generator: compute only what you consume
auto gen = count(1000000);
{
    int n = 0;
    for (int v : gen) {
        use(v);
        if (++n >= 10) break;
    }
}

Frame overhead

Coroutine frames are usually heap-allocated (often hundreds of bytes even for small coroutines). For millions of tiny coroutines that might hurt; for I/O waits and generators the overhead is usually negligible.

Optimization tips

1. Skip unnecessary suspension
If await_ready() can return true for cached/immediate results, you avoid a suspend/resume pair.

bool await_ready() const {
    return cached_result.has_value();
}

2. Small hot awaitables
co_await cost is dominated by frame setup; profile before micro-optimizing.

3. Generators: consume partially
If you only need ten values from a million-element logical sequence, the generator does ten steps—vectors allocate everything.

4. Compiler optimizations
Benchmark release builds (-O2/-O3); frames can sometimes be optimized.

5. Custom allocators (advanced)
For churn-heavy workloads, a pool for coroutine frames can help—rarely needed for typical I/O code.


11. Production patterns

1. Generator

Use: lazy sequences, line-by-line files, infinite streams, pipelines.

Generator<std::string> readLines(const std::string& path) {
    std::ifstream f(path);
    std::string line;
    while (std::getline(f, line)) {
        co_yield line;
    }
}

for (const auto& line : readLines("data.txt")) {
    process(line);
}

2. Async Task

Use: network, files, timers—often with Boost.Asio, libuv, etc.

Task<Response> fetchUrl(const std::string& url) {
    auto conn = co_await connectAsync(url);
    auto data = co_await readAsync(conn);
    co_return parseResponse(data);
}

3. State machines (games / UI)

Use: linearize multi-step behavior; co_await “next frame” or “next event.”

Task<void> gameLoop() {
    while (running) {
        co_await waitForNextFrame();
        update();
        render();
    }
}

4. Pipelines

Chain generators—combine with the Generator article (#23-2).

Generator<int> filter(Generator<int> src, auto pred) {
    for (int x : src) {
        if (pred(x)) co_yield x;
    }
}

auto nums = count(100);
auto evens = filter(std::move(nums), [](int x) { return x % 2 == 0; });

5. Cancel / timeout

Pair awaitables with cancellation tokens or deadlines.

struct CancellableAwaitable {
    std::atomic<bool>* cancelled;

    bool await_ready() const { return cancelled->load(); }
    void await_suspend(std::coroutine_handle<> h) {
        // register h and cancelled with the loop
        // if cancelled, never call h.resume()
    }
    void await_resume() {
        if (*cancelled) throw std::runtime_error("cancelled");
    }
};

6. Retry loops

Task<Response> fetchWithRetry(const std::string& url, int maxRetries = 3) {
    for (int i = 0; i < maxRetries; ++i) {
        try {
            co_return co_await fetchAsync(url);
        } catch (const NetworkError&) {
            if (i == maxRetries - 1) throw;
            co_await sleepAsync(100 * (i + 1));  // backoff
        }
    }
    __builtin_unreachable();
}

7. Batching generators

template <typename T>
Generator<std::vector<T>> batch(Generator<T> src, size_t n) {
    std::vector<T> buf;
    buf.reserve(n);
    for (T x : src) {
        buf.push_back(std::move(x));
        if (buf.size() >= n) {
            co_yield std::move(buf);
            buf.clear();
            buf.reserve(n);
        }
    }
    if (!buf.empty()) co_yield std::move(buf);
}

Production checklist

  • Implement unhandled_exception() (store or log)
  • Choose final_suspend: use suspend_always if the caller must read the result from the promise
  • Prefer values over dangling references for parameters
  • Track ownership when copying coroutine_handle
  • Never resume concurrently from multiple threads without synchronization
  • Ensure destroy() on scope exit (RAII wrappers)

Libraries

  • C++23: std::generator where available
  • C++20: cppcoro, Boost.Asio coroutine support, or custom Task/Generator types

13. Practical caveats

  • Lifetime: locals live in the frame while suspended—valid only while the owning object/handle lives.
  • Threads: await_suspend may resume on another thread—you synchronize.
  • Standard library: C++20 has no std::generator; C++23 adds it. On C++20, roll your own or use a library.
  • Exceptions: always store or log in unhandled_exception()—otherwise failures can vanish.

Debugging

1. Coroutine never resumes
Check that something calls resume() after await_suspend registered the handle.

2. done() is true but no value
final_suspend returning suspend_never may destroy the frame before you read the promise—use suspend_always and a clear read/destroy protocol.

3. Crash inside resume()
Likely resume on a destroyed handle—audit ownership.

4. GDB / LLDB
Frames live on the heap; inspect coroutine_handle and handle.promise().



Keywords

Search terms that match this article: C++20 coroutines, co_await co_yield co_return, coroutine basics, async, promise_type, coroutine_handle.

Summary

ItemMeaning
co_yieldYield one value and suspend (generators)
co_awaitSuspend until the awaitable completes
co_returnEnd the coroutine (optional value)
promise_typeCustomize behavior via the return type
coroutine_handleResume, test completion, destroy frame
LifetimeFrame valid only while handle/object lives

Scenario guide

ScenarioPatternNotes
Lazy sequences (file lines, infinite series)co_yield + GeneratorMemory-efficient partial consumption
Async I/O (network, files)co_await + TaskAsio, libuv, etc.
Game/UI state machinesco_await (frame/event)Keep linear flow
Pipelines (filter/map/batch)Generator chainscompose stages
Timeout / cancelAwaitable + tokenearly exit in await_ready
Retriestry/catch + loopadd backoff

FAQ (repeat)

Q. When do I use this in production?

A. This post covers C++20 coroutine basics—co_await / co_yield / co_return, promise_type, handles and lifetimes, plus generator and async Task examples. Use it for async I/O, streaming, game loops, and anywhere you want sequential code without callback hell.

Q. What should I read first?

A. Follow previous post links at the bottom of each article in order, or open the C++ series index for the full path.

Q. Where can I go deeper?

A. cppreference and each library’s docs. cppcoro and Boost.Asio documentation are especially helpful.

One-liner: co_yield, co_await, and co_return let you suspend and resume coroutines; understanding promise_type and handle lifetime unlocks real-world use. Next, read Generator (#23-2).

References

Next: [C++ practical guide #23-2] Implementing generators: lazy sequences with co_yield

Previous: [C++ practical guide #22-2] Writing custom concepts: domain-specific constraints


  • C++ async work and coroutines | escaping callback hell [#23-3]

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, C++20, Coroutines, co_await, co_yield, co_return, promise_type 등으로 검색하시면 이 글이 도움이 됩니다.