C++ Module 17 – Modules and coroutines
C++ coroutines: co_await, co_yield and co_return
What turns a C++ function into a coroutine, what promise_type and coroutine_handle do, and why coroutine frames cause lifetime bugs.
What you will learn
- Explain what turns a function into a coroutine
- Describe the roles of promise_type and coroutine_handle
- Trace the order in which a coroutine's promise hooks run
- Avoid lifetime bugs in coroutine frames
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what turns a function into a coroutine
- Describe the roles of promise_type and coroutine_handle
- Trace the order in which a coroutine's promise hooks run
- Avoid lifetime bugs in coroutine frames
Before you start
On this page
A function that hands out the squares 1, 4, 9, 16 … one per call has to remember where it stopped between calls. A
coroutine: A function that can suspend in the middle and be resumed later, with its local variables kept in a coroutine frame. In C++ a function is a coroutine when its body uses co_await, co_yield or co_return; nothing in its declaration says so. does it as a plain loop, because it can stop in the middle and continue later,
with its local variables kept in a The storage of one coroutine call: its promise, copies of its parameters and the locals that live across a suspension. It is allocated when the coroutine is called, usually on the heap, and lives until the coroutine is destroyed..
A function becomes a coroutine as soon as its body uses co_await, co_yield or co_return: co_await x suspends
until x is ready, co_yield v hands v to whoever drives the coroutine and suspends, and co_return v finishes it.
Each keyword’s meaning comes from the coroutine’s return type, through its nested promise_type, and a
std::coroutine_handle resumes the paused coroutine or destroys its frame. Below, a small coroutine prints every hook
as it runs on two compilers, and you step through that run.
A coroutine is a state machine the compiler writes for you
Some functions naturally pause. A function that produces a long sequence would like to hand over one value and wait
for the next request; a function that sends a request over the network would like to wait for the answer without
blocking a thread. Without coroutines, both become state machines written by hand: the local variables move into a
struct, and a switch remembers where to continue. A coroutine lets the compiler write that state machine. Here is
the source of the first n squares both ways:
The first n squares |
Written by hand | As a coroutine |
|---|---|---|
| The code you write | A class with a next() member |
A loop that runs co_yield i * i |
Where i and n live |
Members of the object | Locals, kept in the frame |
| Where the next call continues | Wherever a member records | Just after the last co_yield |
In the coroutine, the compiler keeps i and n alive in the frame between one value and the next. C++20 added the
language mechanism and almost no library types: until C++23 brought std::generator, the standard library offered
only building blocks such as std::coroutine_handle, std::suspend_always and std::suspend_never, so most code
used types from libraries or wrote its own.
Checkpoint
Check: A coroutine keeps its place
Three keywords make a coroutine; its return type decides the rest
Nothing in a declaration marks a coroutine: Traced answer(int extra) below is one only because its body uses
co_await and co_return. What each keyword does is up to the coroutine’s types:
| In the body | What happens | Decided by |
|---|---|---|
co_await expr |
Suspends unless expr is ready, until something resumes the coroutine |
The awaiter |
co_yield value |
Passes value to the promise and suspends |
The promise |
co_return value |
Stores a result in the promise and ends the coroutine | The promise’s return_value |
Those types:
promise_typeis a class nested in the return type, and the compiler calls its member functions at fixed points.std::coroutine_handle<P>is a pointer-sized handle to one frame, withresume(),done(),destroy()andpromise(); it copies like a pointer and owns nothing.std::suspend_alwaysandstd::suspend_neverare ready-made The object that co_await works with: await_ready says whether the result is already there, await_suspend runs once the coroutine is suspended, and await_resume gives the value of the co_await expression.: the first always suspends, the second never does.
Checkpoint
Check: Keywords, promise type and handle
Every coroutine body runs inside the same skeleton
The standard turns every coroutine body into the same skeleton: allocate a frame, create the promise, ask it for the
return object, await initial_suspend(), run the body, and await final_suspend() at the end. The promise’s member
functions are called hooks below, because the compiler hooks them into that skeleton. Follow the diagram from the
call down to destroy():
The life of a coroutine and the promise hooks on the way
Text description of the diagram
The diagram follows the coroutine answer(2) of the traced example from top to bottom.
- The caller calls answer(2). The compiler allocates the coroutine frame with operator new, copies or moves the parameters into it and constructs the promise object.
- promise.get_return_object() makes the object the caller will receive, here a Traced that holds the coroutine handle.
- The coroutine awaits promise.initial_suspend(). With suspend_always it stops before running any of its body, and the caller gets the object.
- The body runs when someone calls handle.resume().
- At co_await expr the awaiter is asked await_ready; when it answers no, await_suspend runs and control goes back to whoever resumed the coroutine. The next resume() continues with await_resume, whose result is the value of the co_await expression.
- co_return value calls promise.return_value(value).
- The coroutine awaits promise.final_suspend(). With suspend_always the frame stays alive and handle.done() is true, so the caller can read the result.
- handle.destroy() destroys the promise, then the parameter copies, and frees the frame with operator delete.
Both ends are
A place where a coroutine can stop and hand control back to whoever called or resumed it: each co_await and co_yield, and the initial and final suspension points that the compiler puts around the body..
At the start, suspend_always makes the body wait for the first resume(). At the end, it keeps the frame after the
body so the result can still be read; with suspend_never there, the frame is freed as soon as the body ends.
Because the frame usually outlives the call that created it, everything it refers to must live at least as long;
reference parameters and lambda captures are the two classic ways to break that rule.
Key idea
Every coroutine body runs inside the same skeleton, and its two ends decide its life: initial_suspend says whether
the body waits for the first resume(), and final_suspend whether the frame outlives the body.
Checkpoint
Check: The skeleton around every body
A traced run shows each hook as it happens
This coroutine type does nothing useful, and every one of its hooks prints when it runs:
// A coroutine whose promise reports every step: who calls what, and when.
#include <coroutine>
#include <cstdio>
#include <cstdlib>
#include <new>
struct Traced {
struct promise_type {
int result = 0;
static void* operator new(std::size_t size) {
std::printf(" operator new: a frame of %zu bytes\n", size);
void* frame = std::malloc(size);
if (!frame) throw std::bad_alloc{};
return frame;
}
static void operator delete(void* frame) {
std::puts(" operator delete: the frame is freed");
std::free(frame);
}
promise_type() { std::puts(" promise constructed"); }
~promise_type() { std::puts(" promise destroyed"); }
Traced get_return_object() {
std::puts(" get_return_object");
return Traced{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept {
std::puts(" initial_suspend: wait for the first resume()");
return {};
}
void return_value(int value) {
std::printf(" return_value(%d)\n", value);
result = value;
}
std::suspend_always final_suspend() noexcept {
std::puts(" final_suspend: keep the frame for the caller");
return {};
}
void unhandled_exception() { std::abort(); }
};
std::coroutine_handle<promise_type> handle;
};
struct Pause {
bool await_ready() const noexcept {
std::puts(" await_ready: no, suspend");
return false;
}
void await_suspend(std::coroutine_handle<>) const noexcept { std::puts(" await_suspend: back to the caller"); }
int await_resume() const noexcept {
std::puts(" await_resume: continue with 40");
return 40;
}
};
Traced answer(int extra) {
std::puts(" body starts");
int half = co_await Pause{};
std::puts(" body resumed");
co_return half + extra;
}
int main() {
std::puts("main: call answer(2)");
Traced t = answer(2);
std::puts("main: first resume()");
t.handle.resume();
std::puts("main: second resume()");
t.handle.resume();
std::printf("main: done() = %s, result = %d\n", t.handle.done() ? "true" : "false", t.handle.promise().result);
std::puts("main: destroy()");
t.handle.destroy();
std::puts("main: end");
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra trace.cpp -o trace && ./trace
# GCC 16 asks for a frame of another size:
g++-16 -std=c++23 -Wall -Wextra trace.cpp -o trace-gcc && ./trace-gcc | grep 'operator new'
# and prints every other line exactly as Apple clang does:
./trace-gcc | grep -v 'operator new' > gcc.txt && ./trace | grep -v 'operator new' > clang.txt && diff clang.txt gcc.txt && echo identical
""") Output
$ clang++ -std=c++23 -Wall -Wextra trace.cpp -o trace && ./trace main: call answer(2) operator new: a frame of 32 bytes promise constructed get_return_object initial_suspend: wait for the first resume() main: first resume() body starts await_ready: no, suspend await_suspend: back to the caller main: second resume() await_resume: continue with 40 body resumed return_value(42) final_suspend: keep the frame for the caller main: done() = true, result = 42 main: destroy() promise destroyed operator delete: the frame is freed main: end # GCC 16 asks for a frame of another size: $ g++-16 -std=c++23 -Wall -Wextra trace.cpp -o trace-gcc && ./trace-gcc | grep 'operator new' operator new: a frame of 40 bytes # and prints every other line exactly as Apple clang does: $ ./trace-gcc | grep -v 'operator new' > gcc.txt && ./trace | grep -v 'operator new' > clang.txt && diff clang.txt gcc.txt && echo identical identical
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trace_session.py
Step through the same run, one hand-over of control at a time:
The drawing shows the whole scene; its 11 steps are listed below.
Every step as text
- The call builds the frame. main calls answer(2). Before a single line of the body runs, operator new allocates a frame of 32 bytes and the promise is constructed inside it.
- get_return_object. The promise makes what the caller will receive: a Traced object that holds the coroutine handle.
- initial_suspend: wait. initial_suspend returns suspend_always, so the coroutine stops before its body and the call answer(2) returns to main. Nothing of the body has printed yet.
- First resume(). main calls resume(). The body starts, prints "body starts" and runs as far as co_await Pause{}.
- await_ready: no. co_await asks the awaiter first. await_ready returns false, so the coroutine suspends, and only then does await_suspend run, with the coroutine already suspended.
- Control returns to main. await_suspend returns void, so control goes back to whoever resumed the coroutine: the first resume() returns, and main prints "second resume()".
- Second resume(). The second resume() continues inside the co_await. await_resume returns 40, the value of the whole co_await Pause{} expression, so half is 40.
- co_return hands over 42. The body reaches co_return half + extra, that is 40 + 2, and the compiler calls return_value(42), which stores 42 in the promise.
- final_suspend: keep it. final_suspend also returns suspend_always, so the coroutine stops at its final suspension point instead of freeing its frame. done() is now true.
- main reads the result. main reads promise().result through the handle and prints done() = true, result = 42. That works only because the frame, and the promise inside it, are still alive.
- destroy() frees the frame. main calls destroy(): the promise is destroyed, then operator delete frees the frame. Any use of the handle after this would be a use after free.
The run shows three things. Calling answer(2) builds the frame and the promise before a single line of the body
runs. Each resume() runs the body to its next suspension point: the first stops in co_await Pause{}, the second
reaches co_return, which hands 42 to return_value. And because final_suspend also suspends, main can still
read 42 through promise() until destroy() frees the frame.
The two compilers agree on every step, as the session’s last command checks; only the frame differs, 32 bytes with
Apple clang and 40 with GCC 16, because each compiler lays out frames its own way. The size reaches the promise’s
operator new as its argument, which is how a library can count frame allocations, take them from a pool, or refuse
them.
Checkpoint
Check: The hooks in the order they run
Practise: keywords, hooks and handles
Ten questions on keywords, hooks and handles follow, one at a time, starting where your answers have been weakest.
Practice that adapts to you
Practise this lesson
10 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Every promise hook, what it decides and when it runs Pro: opens with a Pro, Premium or Ultimate pass
The traced example used the hooks that every coroutine needs. This part adds the rest, a copied parameter,
co_yield and an exception, then turns the whole set into a table and a puzzle to check yourself against.
In this part:
- A full trace: a parameter, co_yield and an exception
- What each hook decides: the trade-offs of each choice
- Your turn: order eight lines of a puzzle
4 runnable examples (C++, Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Lifetime bugs in coroutine frames, reproduced under AddressSanitizer Pro: opens with a Pro, Premium or Ultimate pass
A coroutine’s frame usually outlives the call that created it, and anything the frame refers to must live at least as long. This part reproduces the three classic ways to break that rule, catches each with AddressSanitizer on both compilers, and fixes them.
In this part:
- A reference parameter that dies before the coroutine runs, reproduced
- Letting Clang flag a dangling reference parameter at compile time
- A coroutine lambda whose captures die first, reproduced
- One frame destroyed twice, reproduced
- Locks held across a suspension, and why they break
- Rules for frames that outlive their callers
1 diagram · 8 runnable examples (C++, C++ (dangling.cpp), C++ (dangling_fixed.cpp), C++ (lambda_dangling.cpp), C++ (lambda_fixed.cpp), C++ header, Python) · about 9 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What makes a C++ function a coroutine, and what do promise_type and coroutine_handle do?
A function is a coroutine when its body contains co_await, co_yield or co_return; its declaration looks like any other function's. The compiler then needs a promise type, found through the return type, normally as a nested promise_type. The promise's member functions are called at fixed points: get_return_object makes what the caller receives, initial_suspend decides whether the body starts at once, yield_value and return_value receive values, unhandled_exception handles an escaping exception and final_suspend decides what happens at the end. The frame, usually on the heap, holds the promise, the parameter copies and the locals that live across suspensions. A std::coroutine_handle is a non-owning pointer to that frame: resume() continues the coroutine, done() says whether it has finished, promise() reaches the promise and destroy() frees the frame.
-
Mid to senior
A lazy coroutine task, Task load(const std::string& path), passes every unit test but crashes now and then in production, where it is created with a path built inside the calling function and run later by a scheduler. What is going on, and how do you fix it and keep it fixed?
-
Mid to senior
In a code review you see: auto task = [this, config]() -> Task { co_await fetch(); apply(config); }(); and the task is stored and run later. What is wrong with it, even though config is captured by value, and how would you rewrite it?
-
Senior to staff
Design the return type of a coroutine used for background jobs: who owns the frame, when the body starts, how errors reach the caller and what happens if the job object is dropped. Walk through the promise hooks you would write and why.
Key takeaways
- A coroutine is a state machine the compiler writes: its locals stay in a frame, and it continues where it stopped.
- Any of
co_await,co_yieldorco_returnin a body makes a function a coroutine; nothing in its declaration says so. - The
promise_typedecides how a coroutine starts, where values go and how it ends; astd::coroutine_handleresumes and destroys a frame without owning it. - Every body runs inside the same skeleton, and
final_suspenddecides whether the frame outlives the body. - Each
resume()runs the body to its next suspension point, as the traced run showed hook by hook.
Terms in this lesson
- coroutine
- A function that can suspend in the middle and be resumed later, with its local variables kept in a coroutine frame. In C++ a function is a coroutine when its body uses co_await, co_yield or co_return; nothing in its declaration says so.
References
- Working Draft, Programming Languages: C++ (N4950), 9.5.4 Coroutine definitions and 7.6.2.4 Await (ISO/IEC JTC1/SC22/WG21)
- P0912R5: Merge Coroutines TS into C++20 working draft (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: CP.51 and CP.53, coroutines (Standard C++ Foundation)
- AddressSanitizer (Clang documentation) (LLVM Project)
- Attributes in Clang: coro_return_type, coro_wrapper and coro_lifetimebound (LLVM Project)
- Program Instrumentation Options (GCC) (GNU Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress