C++ Coroutine Allocation: Heap vs Stack Elision

This article explores how C++20 coroutines allocate their execution state, contrasting dynamic heap allocation with compiler-driven heap allocation elision (HALO). When a coroutine is invoked, it requires a coroutine frame to store its promise object, function arguments, local variables, and execution state across suspension points. While the language specification permits dynamic allocation via operator new to ensure frames outlive the caller, optimizing compilers can elide this allocation and embed the frame directly into the caller's stack frame when specific lifetime conditions are met.

The Coroutine Frame

Unlike standard functions that execute strictly within the bounds of a call stack, coroutines can suspend execution and resume later. To support this behavior, the compiler generates an internal data structure called the coroutine frame. This structure typically contains:

  • The promise object defined by the coroutine's return type.
  • Copies or moves of the coroutine's parameters.
  • Local variables that persist across suspension points.
  • Internal state tracking, such as the current suspension point index and register states.

Because a coroutine may return control to its caller before its execution completes, its frame cannot always reside on the traditional call stack.

Default Heap Allocation

By default, the compiler allocates the coroutine frame dynamically. When a coroutine is first called, the compiler synthesizes code to allocate the necessary memory before executing the user-written body.

The allocation process follows a structured lookup:

  1. Custom Allocation: The compiler checks if the promise_type defines an overloaded operator new. If present, it uses this overload, optionally passing the coroutine's arguments to allow custom allocators or memory pools.
  2. Global Allocation: If no custom operator new exists, the global ::operator new(std::size_t) is called to allocate memory on the free store.

Deallocation occurs when the coroutine reaches its final suspension point or is explicitly destroyed via its std::coroutine_handle<>::destroy(). The compiler invokes the corresponding operator delete, defined either within the promise_type or globally.

// Conceptual compiler-generated code for coroutine entry
void* frame_ptr = promise_type::operator new(frame_size, /* arguments */);
// ... construct promise, transfer arguments, begin execution ...

Heap Allocation Elision Optimization (HALO)

Dynamic allocation introduces runtime overhead that can degrade performance, especially in high-frequency operations like synchronous generators or immediate-completing tasks. To mitigate this, the C++ standard allows—but does not mandate—Heap Allocation eLision Optimization (HALO).

Under HALO, the compiler replaces dynamic allocation by allocating memory for the coroutine frame directly within the caller's stack frame, treating it essentially as a local variable.

Conditions for Elision

For a compiler (such as Clang or GCC) to elide heap allocation, two primary conditions must be satisfied:

  1. Known Lifetime (Nested Lifetime): The lifetime of the coroutine must be strictly bounded by the lifetime of the caller. The coroutine must complete its execution and be destroyed before the caller's stack frame is popped.
  2. Visibility and Inlining: The compiler must have visibility into the coroutine body and the promise mechanics at the call site. In practice, this requires the coroutine ramp function to be inlined into the caller so the compiler can prove the frame's lifetime guarantees.

If the compiler cannot guarantee that the frame will be destroyed before the caller returns, it must fall back to dynamic heap allocation to avoid stack-use-after-return bugs.

Stack Elision Mechanics

When HALO is applied:

  • The compiler calculates the combined memory footprint of the caller and the coroutine frame.
  • The frame memory is reserved on the stack via stack pointer adjustment at the entry of the caller.
  • Calls to operator new and operator delete are eliminated entirely.
  • The coroutine handle operates on the stack-allocated memory address without any changes to the coroutine's internal logic.

Overriding and Controlling Allocation

Developers can influence how allocation takes place through custom promise definitions:

  • Custom Memory Arenas: By overloading operator new within the promise_type, you can route allocations to pre-allocated buffers, ring buffers, or arena allocators, avoiding global free-store contention even when HALO fails.
  • Enforcing Lifetime Guarantees: Designing coroutines that are destroyed synchronously within the invoking scope (e.g., standard generator patterns consumed via range-based for loops) maximizes the likelihood that the compiler's optimizer successfully performs stack elision.