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:
- Custom Allocation: The compiler checks if the
promise_typedefines an overloadedoperator new. If present, it uses this overload, optionally passing the coroutine's arguments to allow custom allocators or memory pools. - Global Allocation: If no custom
operator newexists, 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:
- 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.
- 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 newandoperator deleteare 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 newwithin thepromise_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
forloops) maximizes the likelihood that the compiler's optimizer successfully performs stack elision.