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:

  1. Before evaluating the task argument, run_async sets a thread-local allocator

  2. The task’s operator new reads this thread-local allocator

  3. 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.

safe_resume's implementation:

inline void
safe_resume(std::coroutine_handle<> h) noexcept
{
    auto* saved = get_current_frame_allocator();
    h.resume();
    set_current_frame_allocator(saved);
}

The cost is two TLS accesses (one read, one write) per .resume() call, negligible compared to the cost of resuming a coroutine.

Two .resume() call sites intentionally do not use safe_resume:

  • symmetric_transfer (MSVC workaround). The calling coroutine is about to suspend unconditionally. When it later resumes, await_resume restores TLS from the promise’s stored environment. Save/restore would add overhead with no benefit.

  • run_async_wrapper::operator(). TLS is already saved in the wrapper’s constructor and restored in its destructor, which bracket the entire task lifetime.

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 to std::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
{
    // ...
};

When HALO Applies

HALO is most effective for immediately-awaited tasks:

// HALO can apply: task is awaited immediately
int result = co_await compute();

// HALO cannot apply: task escapes to storage
auto t = compute();
tasks.push_back(std::move(t));

Measuring HALO Effectiveness

Profile your application to see if HALO is taking effect. Look for:

  • Reduced heap allocations

  • Improved cache locality

  • Lower allocation latency

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

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.

Profile Before Optimizing

Coroutine frame allocation is rarely the bottleneck. Profile your application before investing in custom allocators.