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_yieldflows that produce values one by one - Understand the “wait until complete” pattern with
co_await - Reason about
promise_typeand 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
- Async code gets complicated
- What is a coroutine?
- co_yield and generators
- co_await and suspension
- co_return in detail
- End-to-end coroutine implementations
- promise_type explained
- Coroutine handles and lifetime
- Common mistakes
- Best practices
- Performance comparison
- Production patterns
- 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
onErrorthrough - 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/catchcan 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 (filter → map → batch) 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
Treatexpras the “current value,” yield control to the caller, and resume on the next request from after thisco_yield. - co_await expr
exprmust 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
- Calls the promise’s
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, returntrue→ no 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;
Callspromise_type::return_value(expr)for coroutines that return a value. - co_return;
Callspromise_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
| Method | Role |
|---|---|
| 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 viafinal_suspend - Lazy values:
initial_suspend=suspend_always, compute on firstresume
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
| Approach | Memory | Context switch | Readability | Best for |
|---|---|---|---|---|
| Callbacks | Low | None | Low | Tiny async snippets |
| Coroutines | Medium (frame) | None (cooperative) | High | I/O-bound work, generators |
| Threads | High (stacks) | Yes (OS) | Medium | CPU-bound parallelism |
Generator vs vector
Scenario: integers 0 … 1_000_000
| Approach | Memory (rough) | Startup cost | When it wins |
|---|---|---|---|
| vector<int> | ~4 MiB | full allocation | You need the whole range |
| Generator<int> | ~hundreds of bytes | almost none | Partial 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: usesuspend_alwaysif the caller must read the result from the promise - Prefer values over dangling references for parameters
- Track ownership when copying
coroutine_handle - Never
resumeconcurrently from multiple threads without synchronization - Ensure
destroy()on scope exit (RAII wrappers)
Libraries
- C++23:
std::generatorwhere available - C++20: cppcoro, Boost.Asio coroutine support, or custom
Task/Generatortypes
13. Practical caveats
- Lifetime: locals live in the frame while suspended—valid only while the owning object/handle lives.
- Threads:
await_suspendmay 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().
Related posts (internal)
- C++ async work and coroutines | escaping callback hell [#23-3]
- C++ Generator guide | lazy sequences with
co_yield - C++20 coroutines and Asio | escaping callback hell [#6]
Keywords
Search terms that match this article: C++20 coroutines, co_await co_yield co_return, coroutine basics, async, promise_type, coroutine_handle.
Summary
| Item | Meaning |
|---|---|
| co_yield | Yield one value and suspend (generators) |
| co_await | Suspend until the awaitable completes |
| co_return | End the coroutine (optional value) |
| promise_type | Customize behavior via the return type |
| coroutine_handle | Resume, test completion, destroy frame |
| Lifetime | Frame valid only while handle/object lives |
Scenario guide
| Scenario | Pattern | Notes |
|---|---|---|
| Lazy sequences (file lines, infinite series) | co_yield + Generator | Memory-efficient partial consumption |
| Async I/O (network, files) | co_await + Task | Asio, libuv, etc. |
| Game/UI state machines | co_await (frame/event) | Keep linear flow |
| Pipelines (filter/map/batch) | Generator chains | compose stages |
| Timeout / cancel | Awaitable + token | early exit in await_ready |
| Retries | try/catch + loop | add 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
- cppreference — Coroutines (C++20)
- lewissbaker/cppcoro — C++20 coroutine library
- Boost.Asio — Coroutines
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
Related
- C++ async work and coroutines | escaping callback hell [#23-3]
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- [C++20 Coroutines Complete Guide](/en/blog/cpp-coroutine/
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
이 글에서 다루는 키워드 (관련 검색어)
C++, C++20, Coroutines, co_await, co_yield, co_return, promise_type 등으로 검색하시면 이 글이 도움이 됩니다.