C++ Coroutine Handles and Promises Explained

In C++20, coroutines introduce cooperative multitasking by allowing functions to suspend execution and resume later without losing their state. This mechanism is primarily governed by two components: the promise object, which controls the coroutine from the inside and manages state and return values, and the coroutine handle, a non-owning pointer-like object used from the outside to resume or destroy the coroutine. Together, they coordinate the allocation, state management, suspension points, and final destruction of the coroutine frame.

The Coroutine Frame

When a function containing co_await, co_yield, or co_return is invoked, the compiler allocates a coroutine state (commonly called the coroutine frame) on the heap. This frame stores:

  • The promise object.
  • The function arguments passed by value.
  • Local variables spanning suspension points.
  • The current suspension point to track where execution should resume.

The Promise Object: Internal State Management

The promise object dictates how a coroutine behaves internally. The compiler deduces the promise type from the coroutine's return type using the std::coroutine_traits template, typically expecting an inner type named promise_type.

The promise controls the lifecycle through specific customization points:

  • get_return_object(): Called first when the coroutine is invoked. It creates the object returned to the caller (such as a task, generator, or future).
  • initial_suspend(): Evaluated before the coroutine body runs. Returning std::suspend_always keeps the coroutine paused initially (lazy execution), while std::suspend_never starts execution immediately (eager execution).
  • final_suspend() noexcept: Evaluated after the coroutine finishes execution. Returning std::suspend_always leaves the coroutine frame intact so the caller can read results; std::suspend_never destroys it automatically.
  • yield_value(val): Invoked by co_yield val. It stores the produced value in the promise and returns an awaiter (usually std::suspend_always).
  • return_value(val) or return_void(): Invoked by co_return. It stores the final result or signals completion.
  • unhandled_exception(): Invoked if an exception escapes the coroutine body, allowing the promise to store the exception pointer via std::current_exception().

The Coroutine Handle: External Execution Control

The coroutine handle (std::coroutine_handle<Promise>) is a lightweight, non-owning handle pointing directly to the underlying coroutine frame. It acts as the driver used by callers, schedulers, or event loops to manipulate execution.

Key capabilities of std::coroutine_handle include:

  • resume() (or operator()): Resumes execution of a suspended coroutine from its last suspension point.
  • done(): Returns true if the coroutine has reached its final suspension point or finished.
  • destroy(): Manually deallocates the coroutine frame. This is essential if final_suspend() returned std::suspend_always, preventing memory leaks.
  • promise(): Accesses a reference to the associated promise object.
  • from_promise(Promise&): Static method that obtains the coroutine handle corresponding to a given promise instance.
  • Type-erasure (std::coroutine_handle<void>): A specialized handle type that can hold any coroutine handle regardless of its promise type, ideal for integration with schedulers and executors.

How Promises and Handles Coordinate Suspension

The interaction between the handle and promise coordinates suspension and resumption across execution boundaries:

  1. Initialization: The caller calls the coroutine. The compiler allocates the frame and initializes the promise. The promise creates the return object via get_return_object(), often capturing its own handle via std::coroutine_handle<promise_type>::from_promise(*this) to embed it inside the return object.
  2. Suspension: When a co_await or co_yield expression triggers suspension, the coroutine saves local variables to the frame and pauses. Control returns to the caller or event loop, which retains the coroutine handle.
  3. Communication: Data produced by the coroutine is written to the promise object during suspension (via yield_value or internal awaiter logic). The caller reads this data by querying the promise through handle.promise().
  4. Resumption: When the external system is ready for more data or an asynchronous operation completes, it invokes handle.resume(). The execution resumes right after the suspension point.
  5. Teardown: Once the coroutine reaches co_return, final_suspend() is evaluated. If suspended at the end, the external owner calls handle.destroy() to free the frame and finalize state cleanup.