Stackless Coroutine Frames and Heap Allocation
Stackless Coroutine Frames and Heap Allocation
Section titled “Stackless Coroutine Frames and Heap Allocation”This section covers coroutines as suspendable functions, the stackless vs stackful design trade-off, Coroutine frame layout, heap allocation and elision, std::coroutine_handle<P> for manual lifetime Management, and RAII wrappers for coroutine handles.
Coroutines as Suspendable Functions
Section titled “Coroutines as Suspendable Functions”A coroutine is a function that can be suspended and resumed [N4950 §9.5.4]. Unlike ordinary Functions, which run to completion before returning to the caller, a coroutine may yield control Back to its caller at an arbitrary suspension point and later be resumed from that exact point. The C++20 coroutine mechanism is defined in terms of three keywords:
co_await: suspend execution until an awaitable completes [N4950 §9.5.4].co_yield: suspend execution while producing a value (syntactic sugar forco_awaiton the promise”syield_value).co_return: complete the coroutine and return a value.
Any function body containing one of these keywords is a coroutine [N4950 §9.5.2]. The compiler Transforms the coroutine into a state machine at compile time. This transformation is entirely Implicit — the programmer never writes the state machine manually.
#include <coroutine>#include <iostream>
struct SimpleAwaiter { bool await_ready() const noexcept { return false; } void await_suspend(std::coroutine_handle<>) const noexcept {} void await_resume() const noexcept { std::cout << "resumed\n"; }};
struct SimplePromise { SimplePromise() = default; SimplePromise(const SimplePromise&) = delete; SimplePromise& operator=(const SimplePromise&) = delete; ~SimplePromise() = default;
std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {}
auto get_return_object() { return std::coroutine_handle<SimplePromise>::from_promise(*this); }};
struct SimpleCoroutine { using promise_type = SimplePromise; std::coroutine_handle<SimplePromise> handle;};
SimpleCoroutine my_coroutine() { std::cout << "step 1\n"; co_await SimpleAwaiter{}; std::cout << "step 2\n"; co_await SimpleAwaiter{}; std::cout << "step 3\n";}
int main() { auto coro = my_coroutine(); coro.handle.resume(); coro.handle.resume(); coro.handle.destroy();}Output:
step 1resumedstep 2resumedstep 3:::note The function my_coroutine is a coroutine because its body contains co_await. The Compiler generates a promise_type lookup, allocates a coroutine frame, and transforms the function Body into a state machine. The promise_type member alias tells the compiler which promise type to Use [N4950 §9.5.2]. :::
Stackless vs Stackful Coroutines
Section titled “Stackless vs Stackful Coroutines”C++ chose stackless coroutines — the coroutine frame is a single heap-allocated block, not a Separate stack. This is a deliberate design decision with important trade-offs.
| Property | Stackless (C++) | Stackful (e.g., Boost.Context, goroutines) |
|---|---|---|
| Frame size | Fixed at compile time (known locals) | Dynamic (grows/shrinks like a regular stack) |
| Memory per coroutine | — hundreds of bytes | — megabytes reserved |
| Allocation | Single heap allocation | Separate stack allocation |
| Suspend inside callee | No — only at explicit co_await points | Yes — any function call can be a suspend point |
| Implementation cost | Compiler transforms function into state machine | Context switching (save/restore registers + stack) |
| Migrating between OS threads | Must resume on same or specified thread | Can freely migrate (stack is self-contained) |
| Composability | Requires explicit chaining of coroutines | composable via call stack |
The key limitation of stackless coroutines is that you cannot suspend in a function called by the Coroutine unless that function is itself a coroutine. If a regular function calls co_awaitIt Is a compile error — co_await can only appear in a coroutine body [N4950 §9.5.4].
#include <coroutine>#include <iostream>
struct NestedPromise { NestedPromise() = default; NestedPromise(const NestedPromise&) = delete; NestedPromise& operator=(const NestedPromise&) = delete; ~NestedPromise() = default;
std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {}
auto get_return_object() { return std::coroutine_handle<NestedPromise>::from_promise(*this); }};
struct Nested { using promise_type = NestedPromise; std::coroutine_handle<NestedPromise> handle;};
// This is a coroutine — OK to use co_awaitNested inner() { std::cout << "inner: before\n"; co_await std::suspend_always{}; std::cout << "inner: after\n";}
// This is a coroutine that calls another coroutineNested outer() { std::cout << "outer: calling inner\n"; auto h = inner(); h.handle.resume(); h.handle.resume(); h.handle.destroy(); std::cout << "outer: done\n";}
int main() { auto coro = outer(); coro.handle.destroy();}Coroutine Frame Layout
Section titled “Coroutine Frame Layout”When the compiler transforms a coroutine, it generates a coroutine frame — a single contiguous Block of memory whose layout is implementation-defined [N4950 §9.5.4]. The general structure is:
+-----------------------------------------------+| Coroutine frame (heap-allocated) || || +-------------------------------------------+ || | promise object | || | - coroutine state (initial/running/ | || | suspended/cancelled/done) | || | - stored exception (if any) | || | - return value storage | || +-------------------------------------------+ || | function parameters (copied) | || +-------------------------------------------+ || | local variables that cross suspend points | || +-------------------------------------------+ || | resume/destroy function pointers (vtable) | || +-------------------------------------------+ || | padding / alignment | || +-------------------------------------------+ |+-----------------------------------------------+The frame must be large enough to hold the promise object, a copy of all function Parameters, and every local variable whose lifetime crosses a suspension point. Variables that Are dead before or after a suspension point need not be stored in the frame. The compiler performs a Liveness analysis to minimize frame size [N4950 §9.5.4].
The total frame size can be expressed as:
S = \mathrm{sizeof(\mathrm{promise\_type) + \sum_{i \in \mathrm{live params} \mathrm{sizeof(p_i) + \sum_{j \in \mathrm{live locals} \mathrm{sizeof(l_j) + \mathrm{paddingFrame Size Analysis
Section titled “Frame Size Analysis”The compiler performs a liveness analysis to determine which local variables must be stored in The frame. A variable must be stored in the frame if its lifetime spans at least one suspension Point. Consider:
Task example(int param) { int a = param * 2; // lives across co_await 1 co_await some_awaitable(); // suspension point 1 int b = a + 1; // lives across co_await 2 co_await some_awaitable(); // suspension point 2 int c = b + 1; // dead after co_return — may not need frame storage co_return c;}Variables a and b must be stored in the frame because their lifetimes cross suspension points. Variable c may or may not be stored depending on whether the compiler can prove it does not need To persist (the compiler is conservative here and stores it).
The frame layout is :
+---------------------------+| promise_type | (fixed size)+---------------------------+| int param (copy) |+---------------------------+| int a | (live across suspend 1)+---------------------------+| int b | (live across suspend 2)+---------------------------+| int c | (may be stored)+---------------------------+| coroutine state index | (enum: 0, 1, 2, ..., done)+---------------------------+| padding |+---------------------------+Formal Coroutine State Machine
Section titled “Formal Coroutine State Machine”The compiler transforms every coroutine into a state machine with a finite set of states. Each Suspension point introduces a new state, and the coroutine transitions between states on each resume [N4950 §9.5.4]:
\mathrm{States = \{S_0, S_1, \ldots, S_n, S_{\mathrm{done}\}Where is the initial state (before the first suspension point), corresponds to the -th suspension point, and S_{\mathrm{done} is the terminal state.
The state machine for a coroutine with suspension points has the following transitions:
S_i \xrightarrow{\mathrm{resume} S_{i+1} \quad \mathrm{for i \in \{0, \ldots, n-1\} S_n \xrightarrow{\mathrm{resume} S_{\mathrm{done}At each state, the state machine executes the code segment between the previous suspension point and The next one. The switch on the state index is the first thing executed when the coroutine is Resumed:
// Conceptual compiler-generated state machinevoid coroutine_body(void* frame_ptr) { auto* frame = static_cast<MyFrame*>(frame_ptr); switch (frame->state) { case 0: goto state_0; case 1: goto state_1; case 2: goto state_2; }
state_0: frame->a = frame->param * 2; frame->state = 1; if (!awaiter1.await_ready()) { awaiter1.await_suspend(handle); return; // suspend }state_1: awaiter1.await_resume(); frame->b = frame->a + 1; frame->state = 2; if (!awaiter2.await_ready()) { awaiter2.await_suspend(handle); return; // suspend }state_2: awaiter2.await_resume(); frame->result = frame->b + 1; frame->promise.return_value(frame->result); frame->state = DONE; co_await final_suspend; // final suspension}Heap Allocation of the Coroutine Frame
Section titled “Heap Allocation of the Coroutine Frame”By default, the coroutine frame is dynamically allocated on the heap using the global operator new [N4950 §9.5.4]. The compiler generates a call equivalent to:
There are two guaranteed elision scenarios where the compiler may not allocate on the heap [N4950 §9.5.4]:
Guaranteed elision when the coroutine is in the final suspend point and the return object holds the coroutine handle. If
final_suspendreturnsstd::suspend_neverThe compiler may destroy the frame immediately upon return.When the coroutine result is prvalue and the promise’s
get_return_objectreturns a handle that does not escape.
:::caution The standard does not require that the compiler actually perform elision — it only permits it. In practice, most major compilers (GCC 12+, Clang 16+, MSVC 19.30+) do elide the Allocation in simple cases, but for complex promise types or when the handle escapes, heap Allocation occurs. Always profile if allocation overhead is a concern. :::
Heap Allocation Elision (HALO)
Section titled “Heap Allocation Elision (HALO)”Heap Allocation Elision Optimization (HALO) is a compiler optimization that avoids heap Allocation when the compiler can prove the coroutine frame does not outlive the scope in which it is Created. The conditions for HALO are:
- The coroutine’s return type is a prvalue (not bound to a named variable that escapes).
- The
final_suspendreturnsstd::suspend_never. - The coroutine does not outlive the scope in which it was created (no handle escapes).
When these conditions are met, the compiler allocates the frame on the stack of the caller instead Of the heap. This eliminates the operator new / operator delete overhead.
// HALO-eligible: the coroutine is created and destroyed within the same scopevoid use_elided() { // If compiler can prove 'coro' doesn't escape, frame is stack-allocated auto coro = simple_coro(); coro.resume(); // frame destroyed when coro goes out of scope (if final_suspend = suspend_never)}Custom Allocators
Section titled “Custom Allocators”Custom allocators can be provided via operator new on the promise type or by defining a global Placement operator new overload [N4950 §9.5.4]:
#include <coroutine>#include <cstddef>#include <iostream>
struct TrackedPromise { static inline std::size_t alloc_count = 0; static inline std::size_t dealloc_count = 0;
void* operator new(std::size_t size) { ++alloc_count; std::cout << "Allocating coroutine frame: " << size << " bytes\n"; return ::operator new(size); }
void operator delete(void* ptr, std::size_t size) { ++dealloc_count; std::cout << "Deallocating coroutine frame: " << size << " bytes\n"; ::operator delete(ptr); }
TrackedPromise() = default; TrackedPromise(const TrackedPromise&) = delete; TrackedPromise& operator=(const TrackedPromise&) = delete; ~TrackedPromise() = default;
std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {}
auto get_return_object() { return std::coroutine_handle<TrackedPromise>::from_promise(*this); }};
struct Tracked { using promise_type = TrackedPromise; std::coroutine_handle<TrackedPromise> handle;};
Tracked tracked_coro() { co_await std::suspend_always{}; co_return;}
int main() { auto coro = tracked_coro(); coro.handle.resume(); coro.handle.destroy(); std::cout << "allocs=" << TrackedPromise::alloc_count << " deallocs=" << TrackedPromise::dealloc_count << "\n";}Pool Allocator for Coroutine Frames
Section titled “Pool Allocator for Coroutine Frames”For high-frequency coroutine creation (e.g., in a server handling millions of connections), a pool Allocator can eliminate the overhead of operator new/operator delete:
#include <coroutine>#include <cstddef>#include <new>#include <vector>
class CoroutinePool { struct FreeList { FreeList* next = nullptr; };
static constexpr std::size_t BLOCK_SIZE = 4096; std::vector<void*> blocks_; FreeList* free_ = nullptr;
void* allocate_block(std::size_t size) { std::size_t count = BLOCK_SIZE / size; char* raw = new char[BLOCK_SIZE]; blocks_.push_back(raw); for (std::size_t i = 0; i < count; ++i) { auto* node = reinterpret_cast<FreeList*>(raw + i * size); node->next = free_; free_ = node; } return free_; }
public: void* allocate(std::size_t size) { if (!free_) allocate_block(size); auto* node = free_; free_ = free_->next; return node; }
void deallocate(void* ptr) { auto* node = static_cast<FreeList*>(ptr); node->next = free_; free_ = node; }
~CoroutinePool() { for (void* block : blocks_) delete[] static_cast<char*>(block); }};
struct PooledPromise { static inline CoroutinePool pool;
void* operator new(std::size_t size) { return pool.allocate(size); }
void operator delete(void* ptr, std::size_t) { pool.deallocate(ptr); }
PooledPromise() = default; PooledPromise(const PooledPromise&) = delete; PooledPromise& operator=(const PooledPromise&) = delete; ~PooledPromise() = default;
std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {}
auto get_return_object() { return std::coroutine_handle<PooledPromise>::from_promise(*this); }};
struct Pooled { using promise_type = PooledPromise; std::coroutine_handle<PooledPromise> handle;};std::coroutine_handle<P> for Manual Lifetime Management
Section titled “std::coroutine_handle<P> for Manual Lifetime Management”std::coroutine_handle<P> [N4950 §21.4.4] is a lightweight, copyable, -destructible handle To a coroutine frame. It does not own the frame — it is a non-owning observer. The programmer is Responsible for calling destroy() when the coroutine is no longer needed.
Key members of std::coroutine_handle<P> [N4950 §21.4.4]:
| Member | Description |
|---|---|
handle.done() | Returns true if the coroutine reached its final suspend |
handle.resume() | Resumes a suspended coroutine |
handle.destroy() | Destroys the coroutine frame and calls all destructors |
handle.promise() | Returns a reference to the promise object |
handle.from_promise(p) | Constructs a handle from a promise reference |
coroutine_handle<>() | Default-constructed handle (null) |
handle.operator bool() | true if the handle refers to a coroutine frame |
Handle Nullability and Validity
Section titled “Handle Nullability and Validity”A default-constructed coroutine_handle is a null handle — it does not refer to any frame. The Following operations are undefined on a null handle: resume()``destroy()``promise()And done(). Always check operator bool() before calling these.
A handle becomes invalid after destroy() is called. Using an invalid handle is undefined Behavior [N4950 §21.4.4].
std::coroutine_handle<P>::destroy() for Cleanup
Section titled “std::coroutine_handle<P>::destroy() for Cleanup”Calling destroy() on a handle triggers:
- Destruction of all local variables in the coroutine frame (in reverse order of construction).
- Destruction of the promise object.
- Deallocation of the frame memory (via the matching
operator delete).
After destroy()The handle becomes invalid — using it is undefined behavior [N4950 §21.4.4]. If destroy() is never called and no other mechanism cleans up, the frame leaks.
:::tip RAII wrappers In production code, wrap std::coroutine_handle in an RAII type (e.g., std::unique_ptr with a custom deleter, or a dedicated coroutine class) to ensure destroy() is Called even if an exception propagates.
Complete Example: Handle Lifecycle Management
Section titled “Complete Example: Handle Lifecycle Management”#include <coroutine>#include <iostream>#include <utility>
struct LifecyclePromise { LifecyclePromise() { std::cout << " promise constructed\n"; } LifecyclePromise(const LifecyclePromise&) = delete; LifecyclePromise& operator=(const LifecyclePromise&) = delete; ~LifecyclePromise() { std::cout << " promise destroyed\n"; }
std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {}
auto get_return_object() { return std::coroutine_handle<LifecyclePromise>::from_promise(*this); }};
struct ScopedCoroutine { using promise_type = LifecyclePromise;
ScopedCoroutine(std::coroutine_handle<LifecyclePromise> h) : handle_(h) {} ScopedCoroutine(ScopedCoroutine&& other) noexcept : handle_(std::exchange(other.handle_, nullptr)) {} ScopedCoroutine& operator=(ScopedCoroutine&& other) noexcept { if (this != &other) { destroy(); handle_ = std::exchange(other.handle_, nullptr); } return *this; } ScopedCoroutine(const ScopedCoroutine&) = delete; ScopedCoroutine& operator=(const ScopedCoroutine&) = delete;
~ScopedCoroutine() { destroy(); }
void destroy() { if (handle_) { std::cout << " destroying coroutine frame\n"; handle_.destroy(); handle_ = nullptr; } }
bool resume() { if (handle_ && !handle_.done()) { handle_.resume(); return !handle_.done(); } return false; }
bool done() const { return handle_ && handle_.done(); }
std::coroutine_handle<LifecyclePromise> handle_;};
ScopedCoroutine counting_coro(int n) { for (int i = 0; i < n; ++i) { std::cout << " yielding " << i << "\n"; co_await std::suspend_always{}; } std::cout << " coroutine finished\n";}
int main() { std::cout << "Creating coroutine...\n"; auto coro = counting_coro(3);
std::cout << "Resuming...\n"; while (coro.resume()) {}
std::cout << "Exiting scope (RAII cleanup)...\n"; // ~ScopedCoroutine calls destroy() automatically}Output:
Creating coroutine... promise constructed yielding 0Resuming... yielding 1 yielding 2 coroutine finishedExiting scope (RAII cleanup)... destroying coroutine frame promise destroyedSymmetric Transfer and Stack Overflow Prevention
Section titled “Symmetric Transfer and Stack Overflow Prevention”Proof: Symmetric Transfer Prevents Stack Overflow
Section titled “Proof: Symmetric Transfer Prevents Stack Overflow”Claim: Using symmetric transfer (returning a coroutine_handle from await_suspend) for Coroutine-to-coroutine chaining prevents unbounded stack growth.
Proof:
- Consider a chain of coroutines: Where each coroutine
co_awaitS the next. - Without symmetric transfer: When
co_awaitS Theawait_suspendof callsC_1.resume()inside ‘s suspension handler. This is a regular function call, which grows the call stack by one frame. For coroutines, the stack grows by frames. For unbounded This causes stack overflow. - With symmetric transfer: When
co_awaitSawait_suspendreturns the handle of . The compiler generates a tail call from ‘s resume trampoline to ‘s resume trampoline. A tail call reuses the current stack frame, so the stack depth is . - The C++ standard guarantees [N4950 §9.5.4] that when
await_suspendreturns acoroutine_handleThe resumption is performed by returning the handle to the language runtime, which then callsresume()on it. This is equivalent to a tail call. - Therefore, symmetric transfer bounds stack growth to .
Implementation of Symmetric Transfer
Section titled “Implementation of Symmetric Transfer”#include <coroutine>#include <iostream>
struct SymmetricPromise { std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {}
auto get_return_object() { return std::coroutine_handle<SymmetricPromise>::from_promise(*this); }
struct FinalAwaiter { bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend( std::coroutine_handle<SymmetricPromise> h) noexcept { // Symmetric transfer: return continuation instead of calling resume return h.promise().continuation_; }
void await_resume() const noexcept {} };
std::coroutine_handle<> continuation_;};
struct SymmetricTask { using promise_type = SymmetricPromise; std::coroutine_handle<SymmetricPromise> handle; std::coroutine_handle<> continuation_{};
SymmetricTask(std::coroutine_handle<SymmetricPromise> h) : handle(h) {}
struct Awaiter { SymmetricTask& task;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(std::coroutine_handle<> caller) noexcept { task.continuation_ = caller; return task.handle; // symmetric transfer }
void await_resume() const noexcept {} };};
SymmetricTask chain(int depth) { if (depth <= 0) { std::cout << " reached base case\n"; co_return; } std::cout << " depth " << depth << "\n"; SymmetricTask next = chain(depth - 1); co_await SymmetricTask::Awaiter{next};}
int main() { auto task = chain(1000); while (!task.handle.done()) { task.handle.resume(); } task.handle.destroy();}Common Pitfalls
Section titled “Common Pitfalls”- Frame leaks: The most common bug is forgetting to
destroy()a coroutine handle. Iffinal_suspendreturnsstd::suspend_alwaysand the handle is never destroyed, the frame leaks. Always use RAII wrappers. - Double destroy: Calling
destroy()twice on the same handle is undefined behavior. Set the handle to null afterdestroy(). - Resuming a done coroutine: Calling
resume()on a coroutine that has reached its final suspend point is undefined behavior [N4950 §21.4.4]. Always checkdone()beforeresume(). - Frame size surprises: Coroutines with many local variables or large objects (e.g.,
std::arrayor containers) can have very large frames. Profile frame sizes with compiler flags like-fcoroutines=splitor by measuringoperator newsizes. final_suspendreturningstd::suspend_neverwhen the return type needs to observe the result: If the coroutine’s return type (e.g.,Task<T>) reads the result from the promise after the coroutine finishes,final_suspendmust returnstd::suspend_always. Returningstd::suspend_neverdestroys the frame (and the promise) before the result can be read.
See Also
Section titled “See Also”Summary
Section titled “Summary”This topic covers the core concepts of stackless coroutine frames and heap allocation, including underlying theory, practical implementation, and key applications.
Key concepts include:
- core concepts and terminology
- algorithms and computational thinking
- practical implementation
- security and ethical considerations
- applications in the real world
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
:::