co_await, co_yield, co_return
The three coroutine keywords are the only surface area of C++20 coroutines that most users ever touch. Each one is a suspension or completion point that the compiler transforms into an interaction with the coroutine's promise object. Understanding what each keyword does — and crucially, what it does not do — lets you write correct coroutine code and read error messages when something goes wrong.
The rule: keywords make the coroutine
A C++ function becomes a coroutine entirely through the presence of one of these three keywords somewhere in its body. There is no annotation, no base class, no special return type syntax. The compiler detects the keyword and rewrites the function into a coroutine — allocating a frame, wrapping the body, and wiring up all the suspension points. If none of the three keywords appears, the function is a regular function regardless of its return type.
The return type of a coroutine is called the coroutine type. It is not the type of any single yielded or returned value — it is the handle object that the caller receives. Common coroutine types are std::generator<T> (C++23) for generators, and library-defined types like task<T> or lazy<T> for async work. The coroutine type is tied to a promise type that dictates how the three keywords behave in that particular coroutine — covered in detail in the next lesson.
// All three keywords can appear in the same coroutine body.
// Any one of them is sufficient to make the function a coroutine.
#include <generator> // C++23
std::generator<int> demo() {
co_yield 1; // suspend and yield 1 to the caller
co_yield 2; // suspend and yield 2
co_return; // terminate the coroutine (implicit at end-of-body too)
}
// Return type is std::generator<int>, NOT int.
// The caller receives a generator object, not the yielded values directly.
for (int v : demo()) // iterating the generator drives resumption
std::cout << v; // prints 1 then 2co_yield — produce a value and pause
co_yield expr is the simplest coroutine keyword to understand. It does two things atomically: hands the value of expr to the caller (via the promise's yield_value method), and suspends the coroutine. When the coroutine is resumed, execution continues at the point immediately after the co_yield expression. The yielded value itself is an intermediate result — the coroutine is still alive and can yield more values.
co_yield is the right tool for generators: sequences, ranges, or any pattern where a function needs to produce a stream of values one at a time. Each call to the coroutine's iterator produces the next value and re-suspends.
#include <generator>
// Infinite sequence of squares: 0, 1, 4, 9, 16, …
std::generator<long long> squares() {
for (long long i = 0; ; ++i)
co_yield i * i; // yield, suspend, resume here next time
}
// Consume the first 5 squares
#include <ranges>
for (long long v : squares() | std::views::take(5))
std::cout << v << ' '; // 0 1 4 9 16// co_yield can be used inside any control structure
std::generator<int> range(int first, int last, int step = 1) {
for (int i = first; i < last; i += step)
co_yield i;
// Implicit co_return at end of body
}
for (int v : range(0, 10, 2))
std::cout << v << ' '; // 0 2 4 6 8Key distinction: co_yield vs return
co_yield v passes v to the caller and suspends — the coroutine frame remains alive. Plain return is illegal in a coroutine body. The coroutine-equivalent of return is co_return, which terminates the coroutine irreversibly.
co_await — suspend until a result is ready
co_await expr is the most powerful and most complex keyword. It suspends the current coroutine until an asynchronous operation represented by expr completes, then resumes execution with the result. From the programmer's perspective it reads like a synchronous call — you write auto result = co_await someAsyncOp() and the result is available on the next line — but under the hood, the thread is not blocked; other work can run while the operation is in flight.
The expression after co_await must be an awaitable: a type that implements the awaitable protocol. The compiler transforms the co_await expression into calls to three methods on the awaitable:
await_ready() → boolCalled first. If the result is already available (e.g., cached, or a trivially-completed operation), return true to skip suspension entirely — no coroutine frame save, no scheduler involvement.
await_suspend(handle) → void | bool | handleCalled if await_ready() returned false. The coroutine is suspended before this call. The method receives a handle to the suspended coroutine and is responsible for scheduling its resumption (e.g., registering a callback that calls handle.resume() when the I/O completes). Returning void or true means the calling thread continues elsewhere. Returning false immediately resumes the coroutine. Returning another handle transfers execution to that coroutine (symmetric transfer).
await_resume() → TCalled when the coroutine is resumed. Its return value becomes the value of the entire co_await expression. This is where you extract the result of the async operation.
// Conceptual expansion of: auto result = co_await someAwaitable;
// ↓ compiler generates roughly this:
auto&& awaitable = someAwaitable;
if (!awaitable.await_ready()) {
// Suspend the coroutine, register a callback
awaitable.await_suspend(current_coroutine_handle);
// ← caller/scheduler runs here while we're suspended
// ← something calls current_coroutine_handle.resume() later
}
// Resumed! Get the value:
auto result = awaitable.await_resume();You rarely implement the awaitable protocol yourself. Library types (futures, timers, sockets) provide it. The two standard-library awaitables you will see constantly are utility types for controlling suspension explicitly:
#include <coroutine>
// std::suspend_always — always suspends (await_ready returns false)
// Used to suspend the coroutine unconditionally at a given point.
co_await std::suspend_always{}; // suspend, wait for someone to resume me
// std::suspend_never — never suspends (await_ready returns true)
// Used as a no-op await, or as the initial/final_suspend in custom promise types.
co_await std::suspend_never{}; // no-op, execution continues immediately
// Example: a coroutine that prints its own steps and exposes a handle
struct StepCoroutine {
struct promise_type {
StepCoroutine get_return_object() {
return StepCoroutine{
std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; } // start suspended
std::suspend_always final_suspend() noexcept { return {}; } // end suspended
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
};
StepCoroutine steps() {
std::cout << "step 1\n";
co_await std::suspend_always{}; // pause here
std::cout << "step 2\n";
co_await std::suspend_always{}; // pause again
std::cout << "step 3\n";
}
// Manually drive the coroutine:
auto coro = steps();
coro.handle.resume(); // prints "step 1"
coro.handle.resume(); // prints "step 2"
coro.handle.resume(); // prints "step 3", coroutine finishes
coro.handle.destroy();co_return — terminate the coroutine
co_return is the coroutine equivalent of return — it ends the coroutine and optionally provides a final value. After co_return, the coroutine enters its final suspension (controlled by the promise's final_suspend()) and cannot be resumed. The coroutine frame is destroyed either immediately or when the last handle to it is released.
If a coroutine body reaches its closing brace without an explicit co_return, the compiler inserts an implicit co_return; (with no value). This is only valid if the promise type's return_void() is defined — otherwise it is a compile error.
co_return with a value — async task
// Pseudocode using a hypothetical task<T> coroutine type
task<int> computeAnswer() {
auto partial = co_await someAsyncComputation();
co_return partial * 2; // stores the value in the promise
// caller retrieves it via future/task API
}
// The return value of the co_return expression must be compatible
// with the promise's return_value(T) method.co_return void — generator or void task
std::generator<int> countdown(int from) {
while (from > 0)
co_yield from--;
co_return; // explicit termination (could also just fall off the end)
}
// Implicit co_return at end of body — same effect:
std::generator<int> countdown2(int from) {
while (from > 0)
co_yield from--;
// compiler inserts co_return; here
}Choosing the right keyword
I need to produce a sequence of values, one at a time
co_yield. Each co_yield hands a value to the caller and pauses. The coroutine resumes on the next request for a value.
e.g. Generators, lazy ranges, event streams.
I need to wait for an async operation and use its result
co_await. The coroutine suspends without blocking the thread. Execution resumes with the result when the operation completes.
e.g. Async I/O, network requests, timers, inter-coroutine calls.
I need to end the coroutine and optionally return a final value
co_return. Use it like return — with or without a value. The coroutine cannot run after co_return.
e.g. Completing a task<T>, ending a generator after a sentinel condition.
Can I mix co_yield and co_await in one coroutine?
Yes, if the promise type supports both. Standard generator types usually only support co_yield. Custom async coroutine types may support both.
e.g. An async generator that fetches pages of data could co_await a network call inside a co_yield loop.
co_yield vs co_await: the key distinction
Both keywords suspend the coroutine, so new learners often confuse them. The difference is in direction: does the coroutine want to push data out to the caller, or does it want to pull data in from an async source?
co_yield valueDirection: out. I have a value for you.
- The coroutine produces a value
- The caller receives it immediately
- Used in generators / sequences
- Caller drives resumption by asking for the next value
co_await awaitableDirection: in. I need something from you.
- The coroutine waits for an external result
- The operation schedules its own completion
- Used in async I/O / inter-coroutine calls
- The awaitable's scheduler drives resumption
What the compiler generates
Every coroutine is transformed by the compiler into a heap-allocated state machine. The coroutine's local variables become data members of the coroutine frame. The body becomes a switch-based state machine: each suspension point corresponds to a state, and the frame stores which state to resume at. The promise object — part of the frame — provides the hooks that customise co_yield, co_await, and co_return for the particular coroutine type.
// Conceptual view of what the compiler does with a simple generator:
std::generator<int> count_to(int n) {
for (int i = 0; i < n; ++i)
co_yield i;
}
// The compiler transforms this into approximately:
struct __count_to_frame {
promise_type promise; // generator's promise
int n; // parameter
int i = 0; // local variable
int __state = 0; // which suspension point to resume at
void resume() {
switch (__state) {
case 0:
for (; i < n; ++i) {
promise.yield_value(i); // co_yield i
__state = 1;
return; // suspend
case 1: ; // resume here
}
}
// coroutine body complete — final_suspend()
}
};