Frame Allocators
This section explains how coroutine frames are allocated and how to customize allocation for performance.
The Timing Constraint
Coroutine frame allocation has a unique constraint: memory must be allocated before the coroutine body begins executing. The standard C++ mechanism—promise type’s operator new—is called before the promise is constructed.
This creates a challenge. How can a coroutine use a custom allocator when the allocator might be passed as a parameter, which is stored in the frame?
Thread-Local Propagation
Capy solves this with thread-local propagation:
-
Before evaluating the task argument,
run_asyncsets a thread-local allocator -
The task’s
operator newreads this thread-local allocator -
The task stores the allocator in its promise for child propagation
This is why run_async uses two-call syntax:
run_async(executor)(my_task());
// ↑ ↑
// 1. Sets 2. Task allocated
// TLS using TLS allocator
Three patterns split those two calls apart, and two of them do it without any diagnostic. Starting Coroutines lists all three.
The Window
The "window" is the interval between setting the thread-local allocator and the coroutine’s first suspension point. During this window:
-
The task is allocated using the TLS allocator
-
The task captures the TLS allocator in its promise
-
Child tasks inherit the allocator
After the window closes (at the first suspension), the TLS allocator may be restored to a previous value. The task retains its captured allocator regardless.
TLS Preservation
Between a coroutine’s await_resume (which sets TLS to the correct allocator) and the next child coroutine invocation (whose operator new reads TLS), arbitrary user code runs. That code can call .resume() directly, pump a completion queue, or run nested dispatch. If it resumes a coroutine from a different chain on the same thread, the other coroutine’s await_resume overwrites TLS with its own allocator. The original coroutine’s next child would then allocate from the wrong resource.
To prevent this, any code that calls .resume() on a coroutine handle must use safe_resume from <boost/capy/ex/frame_allocator.hpp>:
// In your event loop or dispatch path:
capy::safe_resume(h); // saves and restores TLS around h.resume()
safe_resume saves the current thread-local allocator, calls h.resume(), then restores the saved value. This makes TLS behave like a stack: nested resumes cannot spoil the outer value. All of Capy’s built-in executors (thread_pool, strands, blocking_context) use safe_resume internally. Custom executor event loops must do the same — see Custom Executor for an example.
|
The cost is two TLS accesses (one read, one write) per Two
|
Custom Allocator Requirements
Custom allocators must meet the usual C++ allocator requirements, or be a std::pmr::memory_resource*. The library does not expose a separate public concept for them; a value-type allocator works as a frame allocator when it provides, illustratively:
// Illustrative requirements — not a named public concept:
typename A::value_type;
a.allocate(n) // -> A::value_type*
a.deallocate(p, n);
In practice, any standard allocator works.
Using Custom Allocators
With run_async
Pass an allocator to run_async:
std::pmr::monotonic_buffer_resource resource;
std::pmr::polymorphic_allocator<std::byte> alloc(&resource);
run_async(executor, alloc)(my_task());
Or pass a memory_resource* directly:
std::pmr::monotonic_buffer_resource resource;
run_async(executor, &resource)(my_task());
Default Allocator
When no allocator is specified, run_async uses the execution context’s default frame allocator, typically a recycling allocator optimized for coroutine frame sizes.
Recycling Allocator
Capy provides recycling_memory_resource, a memory resource optimized for coroutine frames:
-
Maintains freelists by size class
-
Reuses recently freed blocks (cache-friendly)
-
Falls back to upstream allocator for new sizes
This allocator is used by default for thread_pool and other execution contexts.
recycling_memory_resource honors only the default new alignment (STDCPP_DEFAULT_NEW_ALIGNMENT, typically alignof(std::max_align_t)). The alignment argument passed to do_allocate/do_deallocate is ignored, so over-aligned requests are not satisfied. This is sufficient for coroutine frames but means the resource is not a drop-in replacement where over-aligned allocations are required.
|
Observing Reuse
You can watch the reuse happen. This memory_resource pools each freed block by size and counts how many allocations come from the heap versus a recycled block:
// A memory resource that pools freed blocks by size. When a coroutine
// frame is freed, its block is kept; the next frame of the same size
// reuses it instead of allocating again -- the strategy that makes
// recycling_memory_resource fast. The two counters let the test observe
// upstream allocations versus reuse.
struct pooling_resource : std::pmr::memory_resource
{
std::size_t upstream = 0; // blocks taken from the heap
std::size_t reused = 0; // blocks served from the freelist
void*
do_allocate(std::size_t bytes, std::size_t) override
{
auto& blocks = pool_[bytes];
if(! blocks.empty())
{
++reused;
void* p = blocks.back();
blocks.pop_back();
return p;
}
++upstream;
return ::operator new(bytes);
}
void
do_deallocate(void* p, std::size_t bytes, std::size_t) override
{
pool_[bytes].push_back(p); // keep the block for the next frame
}
bool
do_is_equal(memory_resource const& other) const noexcept override
{
return this == &other;
}
~pooling_resource() override
{
for(auto& [bytes, blocks] : pool_)
for(void* p : blocks)
::operator delete(p);
}
std::unordered_map<std::size_t, std::vector<void*>> pool_;
};
Run the same task eight times through it, one run at a time:
// Run the same task repeatedly through one pooling resource. The
// first run has an empty pool, so its frames come from upstream.
// Once the task completes, its frames go back into the pool, so
// every later run reuses a freed block of the right size.
pooling_resource pooling;
auto run_once = [&]
{
thread_pool pool(1);
run_async(pool.get_executor(), &pooling)(my_task());
pool.join(); // task done: its frames are back in the pool
};
run_once(); // cold: fills pool
std::size_t const upstream_when_warm = pooling.upstream;
for(int i = 0; i < 7; ++i)
run_once(); // warm: reuses pool
The first run finds an empty pool and takes its coroutine frames from the heap. Each pool.join() returns those frames to the resource, so the next run_async call reuses them. Once the pool is warm, the upstream count stops climbing while the task keeps running — the seven warm runs allocate nothing new. recycling_memory_resource does the same thing with size-class freelists and a lock-free thread-local cache. That is why thread_pool and the other execution contexts install that resource by default.
Frame Allocator Mixin
Most users never need to allocate coroutine frames manually — task<T> and the built-in awaitable types already participate in TLS frame allocation. When you write your own coroutine promise type and want it to use the same fast path, inherit from frame_alloc_mixin:
struct my_coroutine
{
struct promise_type : capy::frame_alloc_mixin
{
// get_return_object, initial_suspend, ...
};
};
frame_alloc_mixin (in <boost/capy/ex/frame_alloc_mixin.hpp>) supplies operator new and operator delete that:
-
Read the thread-local frame allocator set by
run_async(falling back tostd::pmr::get_default_resource()when none is set). -
Bypass virtual dispatch when that allocator is the default recycling memory resource.
-
Store the resolved allocator pointer at the tail of each frame, so deallocation uses the correct resource even if the thread-local allocator has since changed.
This is the same strategy used internally by io_awaitable_promise_base. Use the mixin directly when your promise type does not need the full environment and continuation support that io_awaitable_promise_base provides. The allocation fast path uses thread-local storage and needs no synchronization; the global pool fallback is mutex-protected.
HALO Optimization
Heap Allocation eLision Optimization (HALO) allows the compiler to allocate coroutine frames on the stack instead of the heap when:
-
The coroutine’s lifetime is provably contained in the caller’s
-
The frame size is known at compile time
-
Optimization is enabled
Capy’s task<T> uses the attribute (when available) to enable HALO:
template<typename T = void>
struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE
task
{
// ...
};
Best Practices
Use Default Allocators
For most applications, the default recycling allocator provides good performance without configuration.
Consider Memory Resources for Batched Work
When starting many short-lived tasks together, a monotonic buffer resource can be efficient:
void process_batch(std::vector<item> const& items)
{
std::array<std::byte, 64 * 1024> buffer;
std::pmr::monotonic_buffer_resource resource(
buffer.data(), buffer.size());
for (auto const& item : items)
{
run_async(executor, &resource)(process(item));
}
// All frames deallocated when resource goes out of scope
}
Scope Variables to Reduce Frame Size
Compilers use declaration scope (braces) to decide which variables cross suspend points and must live in the coroutine frame. Variables declared in an outer scope remain in the frame even after their last use, as long as a co_await follows within the same scope.
Wrapping buffer usage in explicit braces can dramatically reduce frame size:
// BAD: buf lives in frame across all subsequent co_awaits
task<> process(stream& s)
{
char buf[4096];
auto [ec, n] = co_await s.read_some(buf);
co_await do_work(buf, n);
co_await s.write_some(reply); // buf wastes 4K in frame
}
// GOOD: braces end buf's lifetime before next suspend
task<> process(stream& s)
{
std::size_t n;
{
char buf[4096];
auto [ec, n_] = co_await s.read_some(buf);
n = n_;
co_await do_work(buf, n);
}
co_await s.write_some(reply); // 4K saved
}
This technique also enables the compiler to overlap variables in the frame. When two variables have completely non-overlapping lifetimes (in separate scoped blocks), the compiler can reuse the same frame memory for both — even on Clang:
// BAD: both arrays in frame simultaneously (8K)
task<> pipeline(stream& in, stream& out)
{
char read_buf[4096];
auto [ec1, n] = co_await in.read_some(read_buf);
char write_buf[4096];
prepare(write_buf, read_buf, n);
co_await out.write_some(write_buf);
}
// GOOD: non-overlapping scopes allow frame reuse (4K)
task<> pipeline(stream& in, stream& out)
{
std::size_t n;
{
char read_buf[4096];
auto [ec, n_] = co_await in.read_some(read_buf);
n = n_;
}
{
char write_buf[4096];
prepare(write_buf, n);
co_await out.write_some(write_buf);
}
}
In the second version, read_buf and write_buf never coexist, so the compiler can place them at the same frame offset — halving the frame’s buffer footprint. This optimization applies to any variables with non-overlapping lifetimes, not just arrays.
GCC vs Clang Frame Sizes
| This section draws on C++20 Coroutines from compiler and library authors' perspective by Chuanqi Xu. |
GCC and Clang use fundamentally different strategies for coroutine frame layout:
-
Clang performs frame layout after middle-end optimizations. Dead variables, unused temporaries, and constant-folded intermediates are eliminated before the frame is sized.
-
GCC performs frame layout in the frontend, before optimizations. Every local variable whose scope spans a suspend point ends up in the frame, even if optimizations would later prove it dead.
The practical consequence is that GCC coroutine frames are often 5-10x larger than Clang’s for the same source code. In one benchmark, the same coroutine produced a 24-byte frame on Clang and a 16,032-byte frame on GCC.
For production coroutine workloads, Clang currently produces substantially better code. If you must use GCC, pay extra attention to variable scoping (above) and consider supplying a custom memory_resource with larger block sizes. Frames above 2048 bytes bypass the default recycling allocator’s pooling.