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:

  1. The expression is converted into an Awaiter object (via operator co_await or promise transformations).
  2. The awaiter's await_ready() method is invoked. If it returns true, execution continues immediately without suspension.
  3. If it returns false, the coroutine is suspended. The compiler saves local variables and registers into the coroutine frame.
  4. 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.
  5. Once resumed from the outside (via handle.resume()), await_resume() executes, returning the result of the co_await expression 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:

  1. The coroutine evaluates <expr> and passes it to promise.yield_value().
  2. The promise object stores or processes the value so the consumer can access it.
  3. yield_value returns an awaitable type (frequently std::suspend_always).
  4. 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).
  5. 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:

  1. 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.
  2. Local variables in the coroutine body are destroyed in reverse order of their construction.
  3. The coroutine executes co_await promise.final_suspend().
    • If final_suspend returns std::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_suspend returns std::suspend_never, the coroutine frame is automatically destroyed immediately upon completion.
  4. 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, evaluates final_suspend, and halts.