Coroutines power async I/O frameworks and generators; using them safely requires knowing what the compiler generates.
A function becomes a coroutine as soon as its body uses co_await, co_yield or co_return. It can then suspend and later resume where it left off. C++20 coroutines are stackless: locals that live across a suspension are stored in a heap-allocated coroutine frame, which the compiler may elide when the coroutine's lifetime is nested in the caller.
The language supplies the mechanism; libraries supply the types. The return type (say Generator<int>) must name a nested `promise_type`, which customises behaviour:
get_return_object()builds what the caller receives, usually holding astd::coroutine_handle<promise_type>.initial_suspend()decides whether the body starts eagerly or lazily;final_suspend()whether the frame stays alive after finishing (it must benoexcept).yield_value(v),return_value(v)/return_void()andunhandled_exception()handleco_yield,co_returnand exceptions.
A `std::coroutine_handle` is a non-owning pointer to the frame, with resume(), done(), destroy() and promise(). Someone must call destroy() exactly once, which is why wrapper types are move-only RAII classes.
co_await expr uses the awaiter protocol: await_ready() (skip suspending?), await_suspend(handle) (store the handle somewhere, such as an I/O completion queue, to resume later) and await_resume() (the result). std::suspend_always and std::suspend_never are trivial awaiters.
The biggest pitfall is lifetime. Reference parameters, and lambda captures for coroutine lambdas, are not copied into the frame, so a coroutine resumed after its caller's arguments died reads dangling memory. Take parameters by value.
C++23 adds std::generator<T> in <generator>, so you rarely write generators yourself; for async work use a library such as cppcoro, Asio or the C++26 std::execution model.
Example
#include <coroutine>
#include <iostream>
#include <utility>
template <typename T>
class Generator {
public:
struct promise_type {
T current{};
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; } // lazy start
std::suspend_always final_suspend() noexcept { return {}; } // keep frame for done()
std::suspend_always yield_value(T v) noexcept { current = v; return {}; }
void return_void() noexcept {}
void unhandled_exception() { throw; }
};
explicit Generator(std::coroutine_handle<promise_type> h) : h_{h} {}
Generator(Generator&& o) noexcept : h_{std::exchange(o.h_, {})} {}
~Generator() { if (h_) h_.destroy(); } // frees the frame
bool next() { h_.resume(); return !h_.done(); }
T value() const { return h_.promise().current; }
private:
std::coroutine_handle<promise_type> h_;
};
Generator<int> fib(int n) { // co_yield makes this a coroutine
int a = 0, b = 1;
for (int i = 0; i < n; ++i) { co_yield a; b = a + b; a = b - a; }
}
int main() {
auto g = fib(8);
while (g.next()) std::cout << g.value() << ' '; // 0 1 1 2 3 5 8 13
std::cout << '\n'; // C++23: std::generator<int> from <generator> replaces all of this
}Remember
- Any of
co_await/co_yield/co_returnmakes a coroutine with a heap frame promise_typecustomises behaviour;coroutine_handleresumes and destroys- Take coroutine parameters by value; references may dangle across suspensions
Watch out for
- Passing a
const std::string&to a lazily started coroutine with a temporary argument — it dangles before the body runs.