C++ Allocators and std::pmr: Build a Batch Arena for Short-Lived Data
Build a bounded C++20 batch arena for 1,500 short-lived strings, measure what the target library requests, and choose a PMR resource that fits the lifetime.
KnowledgeGate Team
Exam prep & CS education

You profile a parser and find allocation calls everywhere, but replacing them is unsafe until you can name a real lifetime boundary. Allocator-aware containers and std::pmr select storage by workload lifetime, and one bounded batch proves the policy. If allocator-aware types are new to you, start with the C++ Tutorial: Complete Learning Path in 12 Weeks.
C++ allocators solve a lifetime-policy problem
Keep three ideas separate. An object's C++ lifetime says when construction and destruction occur. Storage is the bytes that hold that object. Allocation policy says where dynamic storage comes from and how it is reclaimed. A container still owns and destroys its elements, while its allocator or memory resource supplies storage.
Ask two questions before choosing a policy: do most objects die together, and can one thread own the storage? A batch parser that discards every field after processing has a clear bulk-release point. A cache whose entries disappear independently does not. Profile first, change only the resource, then compare the same workload. An allocator is a lifetime tool, not a promise of universal speed.
std::pmr separates container types from memory resources
std::pmr::polymorphic_allocator<T> stores a runtime pointer to a std::pmr::memory_resource. The container remains an ordinary C++ type, while that resource supplies allocation and deallocation behaviour. std::pmr::vector<T> is the convenient alias for a vector using this polymorphic allocator.
Runtime resource choice lets the same PMR vector type use different storage policies without defining another container template.
The chain is container to selected resource to an optional upstream resource. std::pmr::new_delete_resource() is the general heap-backed fallback. std::pmr::null_memory_resource() instead throws std::bad_alloc when the selected resource cannot satisfy a request, which makes an arena's capacity limit visible.
Nested allocation matters. Construct std::pmr::vector<std::pmr::string> with the chosen resource so both the vector block and string payloads use it. std::pmr::vector<std::string> redirects only the vector's own storage. Each ordinary std::string continues to follow its own allocator.
std::pmr batch arena: work 250 records end to end
The C++20 program gives one batch exactly 262,144 bytes. A counting resource records requests before forwarding them to the arena. Its null upstream makes exhaustion throw instead of silently reaching the heap.
Because the wrapper sits between the container and arena, it observes every request, including nested PMR-string allocations through allocator propagation. processed_characters confirms that all 1,500 values are consumed before release.
#include <array>
#include <cstddef>
#include <iostream>
#include <memory_resource>
#include <string>
#include <vector>
class counting_resource final : public std::pmr::memory_resource {
public:
explicit counting_resource(std::pmr::memory_resource* upstream)
: upstream_(upstream) {}
std::size_t requests = 0;
std::size_t requested_bytes = 0;
private:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
++requests;
requested_bytes += bytes;
return upstream_->allocate(bytes, alignment);
}
void do_deallocate(void* p, std::size_t bytes,
std::size_t alignment) override {
upstream_->deallocate(p, bytes, alignment);
}
bool do_is_equal(const std::pmr::memory_resource& other) const
noexcept override {
return this == &other;
}
std::pmr::memory_resource* upstream_;
};
int main() {
alignas(std::max_align_t)
std::array<std::byte, 256 * 1024> storage{};
std::pmr::monotonic_buffer_resource arena(
storage.data(), storage.size(), std::pmr::null_memory_resource());
counting_resource measured_arena{&arena};
{
std::pmr::vector<std::pmr::string> fields{&measured_arena};
fields.reserve(1500);
for (int record = 0; record < 250; ++record) {
for (int field = 0; field < 6; ++field) {
fields.emplace_back(48, char('a' + field));
}
}
std::size_t processed_characters = 0;
for (const auto& field : fields) {
processed_characters += field.size();
}
std::cout << "sizeof string: " << sizeof(std::pmr::string)
<< "\nvector capacity: " << fields.capacity()
<< "\nstring capacity: " << fields.front().capacity()
<< "\nrequests: " << measured_arena.requests
<< "\nrequested bytes: " << measured_arena.requested_bytes
<< "\nprocessed characters: " << processed_characters << '\n';
} // Every client of arena is destroyed here.
arena.release();
}Portable arithmetic:
250 records × 6 fields = 1,500 strings.
1,500 × 48 characters = 72,000 logical characters.
With one null terminator per string, at least 1,500 × 49 = 73,500 character slots must exist inside the objects or their allocated storage.
That last figure is not an arena-size formula. Vector slot size, string capacity, alignment, and growth rules depend on the implementation.
The buffer is deliberately larger than the reported requests because requested bytes and actual arena position are not identical measures. Alignment padding can consume additional space. A bounded test must succeed or throw based on the real resource, not on payload arithmetic alone.
An illustrative Apple clang 21 with libc++ trace reported sizeof(std::pmr::string) = 32, vector capacity 1,500, string capacity 55, 1,501 requests, and 132,000 requested bytes. The check is 1,500 × 32 = 48,000 bytes for one vector block. The 1,500 string blocks request 56 bytes each, including terminators, so 1,500 × 56 = 84,000 bytes. Then 48,000 + 84,000 = 132,000 bytes. Every physical-layout value in this trace is implementation-specific. Successful completion proves that this run needed zero upstream heap allocations; a smaller arena may throw.

std::pmr resource choice should follow the workload
Choose from the lifetime and sharing pattern, not from the class name alone.
Workload | Resource | Why | Boundary |
|---|---|---|---|
Batch or request scratch data |
| Cheap whole-arena release | All clients die together |
Repeated small blocks with mixed individual frees on one thread |
| Freed blocks can be reused | One thread owns access |
Shared pool across threads |
| Internal synchronisation | Resource is shared |
General long-lived ownership | Default or explicit upstream resource | Supports ordinary independent lifetimes | Owners release independently |
Hard-cap testing | Fixed initial buffer plus | Exhaustion becomes | Fixed capacity is enforced |
monotonic_buffer_resource::deallocate does not reclaim an individual block. Consumption grows until release() or resource destruction, so the approach is strong only when the common end boundary is genuine.
C++ allocator lifetimes decide whether references remain valid
Destruction order is part of correctness. In the example, every string and the vector leave their nested scope before arena.release() runs. Never release or destroy a resource while any allocator-aware client might read, destroy, or deallocate through it.
A client's destructor may call deallocate through its stored resource pointer even when a monotonic resource ignores individual reclamation. That pointer must still name a live resource object.
An escaping std::string_view, pointer, iterator, or reference into one of the 1,500 strings becomes dangling after release. If a parsed value must survive the batch, copy it into an owning std::string or a container backed by a longer-lived resource.
The broader C++ Object Lifetime and Storage Duration derives scope, storage duration, construction order and dangling handles through a seven-object trace, while Pointers in C for GATE refreshes address and value reasoning. PMR adds a narrower obligation: every allocator-aware client must be destroyed before its resource is released.

std::pmr traps that invalidate benchmark conclusions
Small-string optimisation can hide allocations. Short values may stay inside each string object, invisible to the resource. Keep the worked 48-character input, print capacity on the target library, and do not generalise the libc++ trace.
Missing reserve(1500) changes the experiment. Vector growth then adds allocation requests and element moves. Reserve in both baseline and PMR versions.
A monotonic arena can hide per-batch growth. Keep null_memory_resource() in bounded tests, catch std::bad_alloc at the batch boundary, and size from measured high-water use rather than logical payload alone.
An unsynchronised pool is not safe for concurrent access. Give each worker its own resource where ownership permits, or use a synchronised resource when sharing is required.
For the same input, record allocation-call count, requested bytes, peak arena use, batch throughput, and p95 latency. One timing run cannot establish a win.
C++ allocator questions test resource choice and propagation
Useful questions ask you to reason in detail, not recite: choose monotonic or pool resources for two stated lifetimes; explain why std::pmr::vector<std::string> does not redirect string payloads; find the view made dangling by arena.release().
Trace both directions carefully. Allocation flows from container to resource to upstream. Safe teardown flows from elements to container to resource release. A complete answer justifies lifetime, threading, and exhaustion behaviour, not merely a PMR class name.
C++ allocators and std::pmr: the short decision rule
Use a monotonic resource when allocations share a proven end boundary. Use a pool when small blocks are freed and reused during a longer session. Keep ordinary ownership when data may escape unpredictably. Always measure the target implementation, include its standard library in the result, and make every resource outlive its clients.
For focused concepts and practice, continue with the C++ Programming Course. For a broader route across coding and systems topics, browse Coding & Skill Development Courses.
Keep learning

C++ Value Categories and Move Semantics: Trace What Actually Moves
Trace a unique allocation through lvalues, xvalues, prvalues, copy and move constructors, named rvalue references, const, and C++17 copy elision.

C++ Rule of Three, Five and Zero: Safe Object Copying and Moving
Follow one four-integer Buffer from a dangerous shallow copy to correct copy and move operations, then remove the lifetime code with std::vector.

C++ Object Lifetime and Storage Duration: Trace Construction, Destruction, and Dangling State
Learn to separate scope, storage duration, lifetime, and ownership. Trace seven objects precisely, then repair dangling pointers, references, and views.

C++ Diamond Problem: Ambiguity, Duplicate State and Virtual Inheritance
See why a C++ diamond duplicates base state, how virtual inheritance shares it, and when qualification, interfaces, or composition is the clearer design.