Master C++20 Coroutines: Suspendable Functions from the Ground Up
Build intuition for C++20 coroutines by implementing a generator and async task, understanding promise_type, co_yield, and co_await end-to-end.
By the end of this page, you will understand what makes a function a coroutine, implement a reusable Generator<T> type from scratch, write a minimal Task<T> for async pipelines, recognize the three lifecycle control points the compiler calls on your behalf, and avoid the three most common lifetime traps.
What and Why
A regular function runs to completion and then returns. A coroutine is a function that can suspend mid-execution, yield control back to its caller, and later resume exactly where it left off β retaining all its local variables across suspensions.
This matters because many real-world problems are naturally expressed as sequences that produce values lazily (infinite ranges, database cursors, network streams) or as async workflows that wait on I/O without blocking a thread. Before C++20, both patterns required manual state machines, callbacks, or heavyweight threads. Coroutines give you the same result with straight-line, readable code.
C++20 introduces coroutines as a language mechanism, not a library: the compiler transforms any function that contains co_yield, co_await, or co_return into a state machine. The standard library deliberately ships no concrete coroutine types β you or a library author supplies those by implementing a promise_type.
Step by Step
Step 1 β The three keywords
| Keyword | What it does |
|---|---|
co_yield expr | Suspend and produce a value to the caller |
co_await expr | Suspend until some awaitable signals completion |
co_return expr | Finish the coroutine, optionally returning a value |
Using any one of them turns the surrounding function into a coroutine. The compiler then requires that the return type of that function expose a nested promise_type.
Step 2 β A minimal generator
promise_type is the hook the compiler uses to ask: "How do I create the return object? What do I do when the coroutine starts, yields, or finishes?"
#include <coroutine>
#include <iostream>
#include <utility>
// A simple owning wrapper around a coroutine handle.
struct Generator {
struct promise_type {
int current_value{};
Generator get_return_object() {
return Generator{Handle::from_promise(*this)};
}
// Suspend immediately so the body doesn't run until the caller asks.
std::suspend_always initial_suspend() noexcept { return {}; }
// Stay suspended after the final statement so we can query done().
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(int v) noexcept {
current_value = v;
return {};
}
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
using Handle = std::coroutine_handle<promise_type>;
explicit Generator(Handle h) : handle(h) {}
~Generator() { if (handle) handle.destroy(); }
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& o) noexcept : handle(std::exchange(o.handle, {})) {}
// Returns false when the coroutine body has finished.
bool next() { handle.resume(); return !handle.done(); }
int value() const { return handle.promise().current_value; }
private:
Handle handle;
};
Generator range(int first, int last) {
for (int i = first; i <= last; ++i)
co_yield i; // suspends here each iteration
}
int main() {
auto gen = range(1, 5);
while (gen.next())
std::cout << gen.value() << '\n'; // prints 1 2 3 4 5
}Compile with: g++ -std=c++20 -o gen gen.cpp
Notice that range looks like an ordinary loop. The compiler secretly allocates a heap frame to hold i, first, last, and a resume point. Each call to handle.resume() continues from the last suspension point.
Step 3 β Make it generic
Hardcoding int is unnecessary. Templatizing Generator only requires lifting current_value to type T:
#include <coroutine>
#include <optional>
#include <utility>
template<typename T>
struct Generator {
struct promise_type {
std::optional<T> current_value;
Generator get_return_object() {
return Generator{Handle::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
template<std::convertible_to<T> U>
std::suspend_always yield_value(U&& v) {
current_value = std::forward<U>(v);
return {};
}
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
using Handle = std::coroutine_handle<promise_type>;
explicit Generator(Handle h) : handle(h) {}
~Generator() { if (handle) handle.destroy(); }
Generator(const Generator&) = delete;
Generator(Generator&& o) noexcept : handle(std::exchange(o.handle, {})) {}
bool next() { handle.resume(); return !handle.done(); }
T& value() { return *handle.promise().current_value; }
private:
Handle handle;
};
Generator<std::string> greetings() {
co_yield "hello";
co_yield "world";
}
int main() {
auto g = greetings();
while (g.next())
puts(g.value().c_str());
}Common Patterns
Pattern 1 β Infinite lazy sequence
Because the coroutine suspends between yields, it is safe to loop forever:
#include <coroutine>
#include <cstdint>
// Re-use the Generator<T> template from Step 3 above.
Generator<uint64_t> fibonacci() {
uint64_t a = 0, b = 1;
for (;;) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// Caller decides how many terms it wants β the sequence never allocates ahead.Pattern 2 β Pipeline composition
Coroutines compose naturally: one generator feeds another.
Generator<int> take(Generator<int> src, int n) {
for (int i = 0; i < n && src.next(); ++i)
co_yield src.value();
}Pattern 3 β Minimal async Task
co_await suspends on any type that satisfies the awaitable protocol (await_ready, await_suspend, await_resume). A trivial always-ready awaitable makes the mechanics visible:
#include <coroutine>
#include <iostream>
struct ImmediateAwaitable {
bool await_ready() const noexcept { return true; } // never actually suspends
void await_suspend(std::coroutine_handle<>) noexcept {}
int await_resume() const noexcept { return 42; }
};
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
};
Task run() {
int result = co_await ImmediateAwaitable{};
std::cout << "Got: " << result << '\n';
}
int main() { run(); }Real async frameworks (ASIO, liburing wrappers) supply awaitables that schedule the resume on I/O completion β the coroutine body stays linear while the machinery underneath is non-blocking.
What Can Go Wrong
Mistake 1 β Forgetting initial_suspend
If initial_suspend returns std::suspend_never, the coroutine body starts running the moment the caller constructs the return object β before the caller can attach any context. For generators, almost always return std::suspend_always.
Mistake 2 β Destroying a non-final-suspended handle
// WRONG β destroys the handle before the coroutine has finished
Generator<int> gen = range(1, 10);
gen.handle.destroy(); // if the body is mid-execution, UBAlways let the Generator destructor handle lifetime, and only destroy after done() returns true or after you've stopped iterating.
Mistake 3 β Capturing a reference to a local across a suspension point
Generator<int*> bad() {
int local = 99;
co_yield &local; // caller resumes later β local is still alive here
// but if the Generator is moved or the frame is somehow invalidated,
// the pointer the caller holds becomes dangling
}Prefer yielding values, not pointers or references into the coroutine frame. If you must yield a view, document the lifetime contract explicitly.
Quick Reference
| Concept | Key type / keyword | Notes |
|---|---|---|
| Make a function a coroutine | co_yield / co_await / co_return | Any one is sufficient |
| Connect return type to machinery | promise_type nested struct | Must be public |
| Control start behaviour | initial_suspend() | suspend_always = lazy |
| Control end behaviour | final_suspend() | suspend_always = safe to query done() |
| Produce a value and pause | co_yield expr | Calls promise.yield_value(expr) |
| Await an async result | co_await awaitable | Calls await_ready/suspend/resume |
| Finish with a value | co_return expr | Calls promise.return_value(expr) |
| Resume / check status | handle.resume() / handle.done() | Always check done() before resuming |
| Clean up the frame | handle.destroy() | Call exactly once, after coroutine is done or abandoned |
What's Next
- Coroutines reference β full grammar rules and the complete awaitable protocol
- Lambda advanced topics β coroutines and lambdas compose in subtle ways worth understanding
- Move semantics advanced β coroutine frames are move-only; understanding move semantics prevents ownership bugs
- Concepts advanced β constraining
TinGenerator<T>with concepts makes error messages dramatically clearer