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:
promise_typeis the control center of a coroutine. The compiler looks forReturnType::promise_typeand 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)(handleco_yield v),return_void()orreturn_value(v)(handleco_return),unhandled_exception()(handle uncaught exceptions).suspend_alwaysandsuspend_neverare the two built-in awaitable types. Returningsuspend_always{}frominitial_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 wantfibonacci()to run until you ask for the first value.coroutine_handle<promise_type>is the handle to the coroutine frame. It providesresume()(continue execution),done()(has the coroutine completed?),destroy()(free the frame), andpromise()(access the promise object). TheGeneratorowns this handle and must destroy it in the destructor to avoid memory leaks.- The range-for support (
begin()/end()) makes generators usable infor (int n : range(1, 11)). Thebegin()call immediately resumes the coroutine to the firstco_yield, making the first value available.operator++resumes to the nextco_yield. Equality withdefault_sentinel_tcheckshandle.done(). - Infinite generators (
fibonacci(),primes(),count()) are safe to use because they only run as many iterations as you request — eachoperator++orhasNext()runs exactly one moreco_yieldand 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:
tracedCoroutine()is called normally. Inside the call: the promise is constructed,get_return_object()creates theTracePromisereturn value,initial_suspend()returnssuspend_always— the coroutine suspends immediately. The caller receives theTracePromiseobject before the coroutine body runs a single line.- After the call returns,
done()isfalse— the coroutine is suspended at the initial suspension point, not yet complete. coro.resume()resumes the coroutine. It runs the body: computesa + b = 30, hitsco_return 30, which callspromise_type::return_value(30)(storing 30), then hitsfinal_suspend()which suspends again.- After
resume()returns,done()istrue(suspended atfinal_suspendcounts as done) andvalue()returns 30. The coroutine is not destroyed yet —final_suspendreturningsuspend_alwayskeeps the frame alive so we can read the value. - When
TracePromisegoes out of scope, its destructor callshandle.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:
- The awaitable protocol is what makes
co_await exprwork 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). FinalAwaiterinfinal_suspend()performs symmetric transfer: instead of returning fromresume(), it directly transfers control to the continuation coroutine (the one that was awaiting us). This avoids O(n) stack growth for chains ofco_await.fetchUserProfilereads sequentially: eachco_await asyncQuery(...)suspendsfetchUserProfileuntil the query completes, then resumes with the result. The code looks synchronous but is structured as a state machine by the compiler.SleepAwaitableshows the custom awaitable pattern:await_suspendtakes the coroutine handle, does something asynchronous (in real code: register with an event loop), and at some point callsh.resume(). In our simplified example it sleeps the thread synchronously — in production code, it would scheduleh.resume()on an event loop without blocking.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 callsresume()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:
FutureAwaitablewrapsstd::future<T>as an awaitable.await_ready()useswait_for(0)to check non-blocking if the future is ready.await_suspend(h)launches a detached thread that blocks on the future and callsh.resume()when ready.await_resume()callsfut.get()to retrieve the result.ImmediateAwaitablehasawait_ready() = true— the coroutine never suspends. The compiler sees “already ready” and skips the suspension machinery entirely, callingawait_resume()inline. This is zero-overhead for pre-computed values.TimerAwaitablefollows the pattern for any time-based or event-based suspension:await_suspendregisters the callback and returns; the suspended coroutine waits invisibly while the timer thread sleeps. When the timer fires,h.resume()wakes the coroutine.- The
await_suspendreturn type controls what happens after suspension:void(just suspend, resume will be called externally),bool(iffalse, don’t suspend after all), orcoroutine_handle<>(symmetric transfer to another coroutine). Thevoidform is used inFutureAwaitableandTimerAwaitable. suspend_neverforinitial_suspend()(as inSimpleTask) means the coroutine body starts running immediately when called.suspend_alwaysmeans 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.




