How co_await, co_yield, and co_return Work in C++
C++20 introduced coroutines, enabling functions to suspend execution
and resume later without blocking the underlying operating system
thread. This non-preemptive multitasking model is driven by three core
operators: co_await, co_yield, and
co_return. Together, these keywords dictate execution flow
by passing control back and forth between the coroutine and its caller:
co_await suspends execution until an operation finishes,
co_yield returns an intermediate value before pausing, and
co_return delivers a final value and terminates the
coroutine's body.
The Coroutine State and Promise Object
When a function uses any of the three coroutine operators, the compiler transforms it into a state machine. Rather than executing on the standard call stack, the coroutine's local variables, parameters, and current point of execution are stored in a heap-allocated coroutine frame.
The execution flow between the caller and the coroutine is
coordinated by an internal customization point known as the
Promise Object
(std::coroutine_traits<...>::promise_type). Each
keyword directly invokes methods on this promise object to alter the
flow of control.
co_await:
Conditional Suspension and Resumption
The co_await operator pauses the coroutine until a
specific condition is met, handing execution back to the caller or an
event loop.
When co_await <expr> is evaluated:
- The expression is converted into an Awaiter object
(via
operator co_awaitor promise transformations). - The awaiter's
await_ready()method is invoked. If it returnstrue, execution continues immediately without suspension. - If it returns
false, the coroutine is suspended. The compiler saves local variables and registers into the coroutine frame. - The awaiter's
await_suspend(handle)is called:- If it returns
void, control immediately returns to the caller or resumer. - If it returns
false, the coroutine is immediately resumed. - If it returns another coroutine handle (symmetric transfer), control transfers directly to that coroutine.
- If it returns
- Once resumed from the outside (via
handle.resume()),await_resume()executes, returning the result of theco_awaitexpression and resuming linear execution.
co_yield:
Emitting Data and Pausing
The co_yield operator produces a sequence of values
lazily, typically used in generators or data streams. It sends a value
to the caller and pauses the coroutine until the caller requests the
next item.
Under the hood, co_yield <expr> is syntactic sugar
for:
co_await promise.yield_value(<expr>);The execution flow proceeds as follows:
- The coroutine evaluates
<expr>and passes it topromise.yield_value(). - The promise object stores or processes the value so the consumer can access it.
yield_valuereturns an awaitable type (frequentlystd::suspend_always).- The coroutine suspends itself, and control returns directly to the caller at the site of the resumption call (such as a generator’s iterator increment).
- The coroutine remains frozen until the consumer explicitly resumes it.
co_return:
Finalizing Execution
The co_return operator marks the end of a coroutine's
execution lifecycle. Unlike a standard return statement,
co_return coordinates with the promise object to clean up
resources and notify waiting entities that no more work will be
done.
When co_return <expr> (or co_return;)
is encountered:
- The compiler executes:
promise.return_value(<expr>)if an expression is provided.promise.return_void()if no expression is provided or the coroutine exits naturally.
- Local variables in the coroutine body are destroyed in reverse order of their construction.
- The coroutine executes
co_await promise.final_suspend().- If
final_suspendreturnsstd::suspend_always, the coroutine pauses in a completed state, keeping the coroutine frame alive so the caller can extract results or inspect state before destroying the handle explicitly. - If
final_suspendreturnsstd::suspend_never, the coroutine frame is automatically destroyed immediately upon completion.
- If
- Control returns to the resumer for the final time. Subsequent attempts to resume a completed coroutine result in undefined behavior.
Summary of Control Transfers
- Calling/Resuming: Caller calls a coroutine or
invokes
handle.resume()\(\rightarrow\) execution enters the coroutine frame. co_await: Coroutine checks condition \(\rightarrow\) if pending, saves state and returns control to caller.co_yield: Coroutine writes a value \(\rightarrow\) saves state and returns control to caller.co_return: Coroutine finishes \(\rightarrow\) records final result, cleans up local variables, evaluatesfinal_suspend, and halts.