Statelessness 03 — PMR request arena
The runnable companion to compendium Doc 03: the layered monotonic + pool arena, per-request allocation, the bulk-release-vs-per-object asymmetry, and the lifetime trap caught by AddressSanitizer.
The full source for this example lives in
examples/statelessness/03-pmr/— clone the repo,cdin, and./demo.sh.
Compendium reference: Doc 03 — PMR and the request arena
Tutorial sections: §7 Memory Management + §6 STL, Layout & C++20/23 Containers
Where 02-raii
showed a RequestContext carrying a small arena, this example goes deep on
the arena itself: the layered resource recipe, per-request allocation, the
release-cost asymmetry, and the lifetime trap that catches everyone the
first time.
Why this matters
Per-request work allocates: tokens parsed out of a payload, intermediate
maps, scratch strings. On the default global allocator, every one of those
allocations is a separate malloc, every release a separate free, and
the cost is both the per-call overhead and the variance — the tail of a
latency distribution is where allocator contention and fragmentation show
up. Under a container this matters twice over:
- Per-request memory must be bounded and predictable. A handler that allocates an unbounded amount of scratch on the global heap makes the whole process’s memory footprint a function of request shape — exactly what a memory cgroup punishes with an OOMKill.
- Tail latency is the SLO that breaks first. PMR’s reliable win is not mean throughput; it is shrunken tail-latency variance and bounded per-request memory. A monotonic arena turns N scattered frees into a single pointer reset, removing a class of tail spikes.
- Request-scope memory should have request-scope lifetime. The arena is born with the request and dies with it. That is the same discipline as RAII, expressed in the allocator — and it makes the lifetime trap below the one thing you have to get right.
What this demo shows
The layered request arena (src/request_arena.hpp). A
monotonic_buffer_resource for O(1) bump allocation, layered over an
unsynchronized_pool_resource that recycles size-classed blocks, over
new_delete_resource. The whole arena releases together when it leaves
scope — no per-object destructor walk for trivially-destructible scratch.
The type is deliberately non-movable: the monotonic resource holds a
pointer into the arena’s own buffer, so moving it would dangle that
pointer.
mode=arena splits the payload into arena-allocated tokens
(std::pmr::vector<std::pmr::string>) and counts them in a
std::pmr::unordered_map, all freed in bulk at scope end.
mode=bench times allocating + releasing N objects through the arena
versus N individual new/delete pairs, returning both numbers. Read them
as context, not a scoreboard: the reliable win is bounded, predictable
per-request memory and shrunken tail-latency variance — not mean
throughput. glibc’s allocator is fast, so at modest N the heap can match or
beat the arena’s wall clock. The architectural reason is the point; the
numbers are confirmation when they confirm.
The lifetime trap is a standalone AddressSanitizer binary, pmr-trap.
It stores a std::string_view into arena memory in a process-scoped cache,
lets the arena die, then reads the cache — ASan reports
heap-use-after-free. The nonzero exit is the point: without ASan the bug
is silent until the freed memory is reused, the kind of “random” production
failure that’s miserable to chase. It lives in its own binary because you
cannot safely host a use-after-free inside a long-lived service.
How to run
cd examples/statelessness/03-pmr
./demo.sh # build + arena + bench + the ASan trap
./demo.sh --keep # leave the service running afterward
./demo.sh --clean # tear down
The first build reuses the same gRPC chain as 02-raii, so a warm Conan cache on the host makes it fast.
CI verification: scripts/test-stateless-demo-03-pmr.sh.
What you’ll see
Representative output on a Fedora 44 host with gcc-toolset-14 and Podman 5.x — the arena path, the benchmark, then the deliberate ASan trap firing:
==> mode=arena (split payload, count tokens, free in bulk)
tokens parsed : 64
distinct tokens : 41
arena high-water: 7.8 KiB (single bulk release at scope end)
status: OK
==> mode=bench (arena vs new/delete, N=100000)
arena alloc+release : 0.83 ms
new/del alloc+release : 1.10 ms
(context, not a scoreboard — the win is variance + bounded memory)
==> lifetime trap (pmr-trap, AddressSanitizer)
=================================================================
==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 1 at 0x... thread T0
#0 ... StringView::operator[]
#1 ... main pmr_trap.cpp:NN
freed by thread T0 here:
#0 ... operator delete
#1 ... RequestArena::~RequestArena
==1==ABORTING
pmr-trap exit code: 1 <-- the point: the bug is caught, loudly
How to read the output
mode=arenafrees everything in one bulk release. The “arena high-water” is the peak the request used; it all comes back with a single pointer reset when the arena leaves scope, not 41 individual frees.- The bench numbers are confirmation, not the headline. If the arena is faster, good; if the heap matches it at small N, that’s expected — glibc’s allocator is fast. The durable win is the tail: under contention and at larger N the arena’s variance is far lower. Don’t treat a close wall-clock race as a failure of PMR.
- The ASan trap exits nonzero on purpose. The
heap-use-after-freereport with a nonzero exit code is the success condition for that step. A clean exit there would mean the trap didn’t fire — which under ASan would itself be the surprise. - The freed-by frame names
~RequestArena. That is ASan telling you exactly which destructor released the memory the danglingstring_viewstill points at — the lifetime lesson made concrete.
Files
src/request_arena.hpp— the layered monotonic + pool arena (non-movable; pointer into its own buffer)src/main.cpp— gRPC server: thearenawork path, thebenchpath, and a healthzsrc/client.cpp— one-shot client that drives the two modessrc/pmr_trap.cpp— standalone AddressSanitizer lifetime-trap demoproto/processor.proto—MemoryProcessor;mode = arena | benchCMakeLists.txt— svc + client + pmr-trap (ASan, static libasan)conanfile.py— gRPC + protobuf + abseil (no OTel)Containerfile— multi-stage UBI 9 → ubi-minimal + libstdc++compose.yml— single service; read-only rootfs + tmpfsdemo.sh— build, bring up, run arena + bench + the trap
Caveats and gotchas
- The arena is non-movable on purpose. The monotonic resource holds a
pointer into the arena object’s own buffer; a move would leave that
pointer dangling. Don’t add move operations to make it fit in a container
— keep it pinned where it lives (in the
RequestContext, on the stack). unsynchronizedmeans one thread. The pool resource is theunsynchronizedvariant, which is correct because a request arena is touched by exactly one thread for the request’s lifetime. Sharing it across threads is undefined behaviour; the threading rules are in the threading example.- ASan shadow memory in containers. The demo runs
pmr-trapdirectly and on most kernels ASan maps its shadow memory fine. If you hitShadow memory range interleavesat startup (high ASLR entropy on newer kernels), the in-containersetarch -Rfix doesn’t apply — thepersonalitysyscall it needs is blocked by seccomp — so apply a host-side mitigation:sudo sysctl vm.mmap_rnd_bits=28or run with--security-opt seccomp=unconfined. The trap itself is real either way. For what shadow memory is and the full set of failure modes, see §12 Analysis & debugging. - The bench is a wall-clock micro-measurement. It is not a substitute for measuring your real handler under real load. Treat it as a sanity check on the asymmetry, not a production benchmark.
Source materials
This example deepens material from the project’s bibliography:
- Andrist & Sehr, C++ High Performance 2e, ch. 7 — custom memory
management and
std::pmr; the monotonic + pool layering recipe in detail - Iglberger, C++ Software Design, ch. 7 — value semantics and ownership; why the arena owns its memory and hands out non-owning views
- Enberg, Latency, ch. 4 — the variance-and-tail framing that explains why the arena’s reliable win is the tail, not the mean
Linked tutorial sections
- §7 Memory Management — allocators, huge pages, and cgroup memory limits; this example is the request-scope half of that section’s allocator story (the process-scope half is the allocator demo in the main track).
- §6 STL, Layout & C++20/23 Containers —
the
std::pmrcontainer variants (pmr::vector,pmr::string,pmr::unordered_map) used here, and the layout reasons they pay off. - §12 Analysis & Debugging — AddressSanitizer, shadow memory, and the container failure modes the lifetime trap exercises.
Where it sits in the compendium
PMR builds directly on the RAII request scope from
Doc 02; the
threading rules behind the unsynchronized choice are in
Doc 05,
and the cache fix for the lifetime trap — a cache that owns its entries —
is in
Doc 07.
The capstone
(Doc 10)
carries this exact arena as a RequestContext member.