Every standard container that owns memory gets it from an allocator. Most
of the time that's std::allocator<T>, which calls ::operator new. This
skill is about the other times: when you want all of a request's memory in one
arena you throw away at the end, when you want to count or limit allocations,
or when the heap is off-limits entirely. C++17's polymorphic allocators
(std::pmr) make that practical — once you know the three rules that bite:
propagation, uses-allocator construction, and lifetime.
1. What an allocator is
An allocator is a small value type that hands out raw, uninitialised memory
for Ts. The minimum a container needs:
#include <cstddef>
#include <cstdlib>
#include <new>
#include <print>
#include <vector>
template <class T>
struct Mallocator {
using value_type = T;
Mallocator() = default;
template <class U>
Mallocator(const Mallocator<U>&) noexcept {} // "rebind" conversion
T* allocate(std::size_t n) {
if (void* p = std::malloc(n * sizeof(T))) return static_cast<T*>(p);
throw std::bad_alloc{};
}
void deallocate(T* p, std::size_t) noexcept { std::free(p); }
// Stateless: any two Mallocators can free each other's memory.
friend bool operator==(const Mallocator&, const Mallocator&) = default;
};
int main() {
std::vector<int, Mallocator<int>> v{1, 2, 3};
v.push_back(4);
std::println("{}", v);
}Everything else — pointer, construct, destroy, max_size, the
propagation traits — has a default that containers obtain through
std::allocator_traits<Alloc>. Containers never call the allocator
directly; they go through the traits.
Rebind. A std::list<int, A> never allocates ints; it allocates nodes.
It converts A (an allocator of int) to an allocator of its node type with
allocator_traits<A>::rebind_alloc<Node>. That's why the converting
constructor above exists.
Two consequences worth knowing:
- The allocator is part of the type.
std::vector<int>andstd::vector<int, Mallocator<int>>are different types: you can't pass one where the other is expected. - Equality means interchangeability.
a == bpromises that memory fromacan be freed byb. Stateless allocators are always equal; stateful ones (pointing at different pools) usually aren't.
2. Stateful allocators and propagation
A stateful allocator carries data — typically a pointer to the pool it
allocates from. Now copying and moving containers raises a question: when you
assign a = b, does a keep its own allocator or take b's?
The answer is in three traits, all false by default:
| trait | true means |
|---|---|
propagate_on_container_copy_assignment |
a = b makes a use b's allocator |
propagate_on_container_move_assignment |
a = std::move(b) makes a use b's allocator |
propagate_on_container_swap |
swap(a, b) swaps allocators too |
And copy construction calls select_on_container_copy_construction() to
decide the new container's allocator.
The surprise is move assignment when the trait is false and the allocators
differ: a can't steal b's buffer (it would later free it through the
wrong pool), so it must allocate its own memory and move the elements one
by one. std::move on a container becomes O(n) and may allocate.
3. Polymorphic allocators
Making the allocator part of the type spreads it through every signature. C++17
fixed that with one allocator type for everything:
std::pmr::polymorphic_allocator<T>, which holds a pointer to a
std::pmr::memory_resource — an abstract class with a virtual
allocate/deallocate/is_equal. Different strategies are different
resources; the container type stays the same.
namespace std::pmr {
class memory_resource {
public:
void* allocate(size_t bytes, size_t align = alignof(max_align_t));
void deallocate(void* p, size_t bytes, size_t align = alignof(max_align_t));
bool is_equal(const memory_resource& other) const noexcept;
private:
virtual void* do_allocate(size_t bytes, size_t align) = 0;
virtual void do_deallocate(void* p, size_t bytes, size_t align) = 0;
virtual bool do_is_equal(const memory_resource& other) const noexcept = 0;
};
}polymorphic_allocator, which holds a memory_resource*. Which resource it points at is a runtime choice; the container's type never changes.std::pmr::vector<T> is just std::vector<T, std::pmr::polymorphic_allocator<T>>,
and likewise pmr::string, pmr::unordered_map, and so on. A pmr container
constructed without a resource uses std::pmr::get_default_resource(),
which is new_delete_resource() (plain operator new) unless you change it
with set_default_resource.
Standard resources:
| resource | behaviour |
|---|---|
new_delete_resource() |
operator new / operator delete |
null_memory_resource() |
every allocation throws std::bad_alloc |
monotonic_buffer_resource |
bump allocator: optional initial buffer, then grows from an upstream; deallocate does nothing; release() frees everything |
unsynchronized_pool_resource |
pools of fixed-size blocks per size class; reuses freed blocks; single-threaded |
synchronized_pool_resource |
the same, thread-safe |
polymorphic_allocator never propagates: all three traits are false, and
copy construction selects a default-constructed allocator — the default
resource, not the source's. A copy of an arena container is not in the
arena unless you say so.
4. Uses-allocator construction: nested containers
An arena is only useful if everything goes in it — including the strings
inside the vector inside the map. That happens through uses-allocator
construction: when a pmr container constructs an element whose type also
uses polymorphic_allocator, it passes its own allocator along.
#include <memory_resource>
#include <print>
#include <string>
#include <vector>
int main() {
std::pmr::monotonic_buffer_resource arena;
std::pmr::vector<std::pmr::string> names(&arena);
names.emplace_back("a name long enough to need the heap");
std::println("element in arena: {}", names[0].get_allocator().resource() == &arena);
}names passes its allocator to every pmr::string it constructs, so their characters land in the arena. A pmr::string you build yourself first lands in the default resource.The trap is temporaries. Any pmr::string you create yourself, before it
reaches the container, uses the default resource:
std::pmr::unordered_map<std::pmr::string, int> m(&arena);
m["a long key that does not fit in SSO"] = 1; // temporary pmr::string from
// the DEFAULT resource, then
// copied into the arena
m[std::pmr::string("a long key ...", &arena)] = 1; // built in the arena directlyThe map still ends up with its key in the arena (the node's key is constructed with the map's allocator), but the temporary cost a heap allocation and a copy. In code that must not touch the heap, that's a bug.
Note also that std::string and std::pmr::string are different types:
there's no implicit conversion between them. Go through std::string_view
(the constructor is explicit).
5. Arenas: monotonic_buffer_resource
A monotonic resource is the simplest fast allocator there is: a pointer
that only moves forward. Individual deallocate calls are no-ops; memory comes
back all at once when you call release() or destroy the resource.
Worked example: a per-request frame
A server handles one request at a time. Everything a request allocates can be thrown away together, so give each request an arena backed by a fixed buffer, and make running out of it an error instead of a silent heap fallback:
#include <array>
#include <cstddef>
#include <memory_resource>
#include <print>
#include <string>
#include <string_view>
#include <vector>
std::pmr::vector<std::pmr::string> split(std::string_view text, std::pmr::memory_resource* mr) {
std::pmr::vector<std::pmr::string> out(mr);
std::size_t start = 0;
while (start <= text.size()) {
std::size_t end = text.find(' ', start);
if (end == std::string_view::npos) end = text.size();
if (end > start) out.emplace_back(text.substr(start, end - start)); // in mr
start = end + 1;
}
return out;
}
int main() {
alignas(std::max_align_t) std::array<std::byte, 4096> buffer;
std::pmr::monotonic_buffer_resource frame(buffer.data(), buffer.size(),
std::pmr::null_memory_resource());
for (std::string_view request : {"GET /index.html", "POST /api/answers with a long body"}) {
auto words = split(request, &frame);
std::println("{} words, first: {}", words.size(), words.front());
// words dies here, then the whole arena is reset in one step:
}
frame.release();
}next; deallocate does nothing; release() resets everything at once.Three design choices carry the example:
- The buffer is on the stack: no heap at all for small requests.
- The upstream is
null_memory_resource(): if the buffer runs out, allocation throwsstd::bad_allocinstead of quietly using the heap. Usenew_delete_resource()instead if overflow is acceptable but should be rare. release()is O(1) — no per-object frees.
emplace_back(text.substr(...)) constructs the pmr::string inside the
vector, from a string_view, with the vector's allocator. No temporary.
Faded example — your turn
Write join(words, sep, mr) returning a std::pmr::string built entirely in
mr. Which constructor gives the result string the right resource, and why
is std::pmr::string result; wrong?
std::pmr::string join(const std::pmr::vector<std::pmr::string>& words,
std::string_view sep, std::pmr::memory_resource* mr) {
std::pmr::string result(/* ??? */);
for (std::size_t i = 0; i < words.size(); ++i) {
if (i > 0) result += sep;
result += words[i];
}
return result;
}Answer
std::pmr::string result(mr);. A default-constructed pmr::string uses the
default resource, so every growth step of result would hit the heap even
though the inputs live in the arena. (Returning it is fine: the allocator
moves with the string.)
6. Pool resources
unsynchronized_pool_resource keeps free lists per block size, so memory freed
by one container is reused by the next — unlike a monotonic arena, it
doesn't grow without bound in long-lived, churning workloads (a cache that
inserts and erases forever). synchronized_pool_resource is the thread-safe
version. Rule of thumb:
- short-lived, allocate-then-discard (a request, a frame, a parse): monotonic;
- long-lived with churn: pool;
- both: a pool whose upstream is a monotonic arena.
7. Lifetime: a resource must outlive everything allocated from it
A pmr container stores a raw pointer to its resource. If the resource dies first, the container's memory is gone — or, for a monotonic resource with a heap upstream, freed:
std::pmr::vector<int> make() {
std::pmr::monotonic_buffer_resource r; // local resource
std::pmr::vector<int> v(&r);
v.assign(100, 1);
return v; // v still points at r's memory — which r frees right now
}ASan reports the next access as heap-use-after-free (or stack-use-after-return if the buffer was on the stack). The rules:
- Resources outlive containers. Declare the resource before the containers that use it, in the same or an outer scope.
- Results that must outlive a frame must be copied out into containers with
a longer-lived resource (or ordinary
std::containers) before the frame is released. release()invalidates everything allocated from the resource — even containers that are still alive.
8. Measuring allocations
The simplest instrument is a resource that counts and forwards. It's also the cleanest way to test allocation behaviour: wrap the upstream, run the code, check the numbers.
#include <cstddef>
#include <memory_resource>
#include <print>
#include <vector>
class Counting : public std::pmr::memory_resource {
public:
explicit Counting(std::pmr::memory_resource* up) : up_(up) {}
std::size_t count = 0;
private:
void* do_allocate(std::size_t bytes, std::size_t align) override {
void* p = up_->allocate(bytes, align); // may throw: count after
++count;
return p;
}
void do_deallocate(void* p, std::size_t bytes, std::size_t align) override {
up_->deallocate(p, bytes, align);
}
bool do_is_equal(const memory_resource& other) const noexcept override { return this == &other; }
std::pmr::memory_resource* up_;
};
int main() {
Counting c(std::pmr::new_delete_resource());
std::pmr::vector<int> v(&c);
v.reserve(100);
for (int i = 0; i < 100; ++i) v.push_back(i);
std::println("allocations: {}", c.count); // 1: reserve guarantees no regrowth
}Note the order in do_allocate: count after the upstream call succeeds, so
a throwing upstream doesn't leave the counters lying. And do_is_equal is
identity: two different counting resources are not interchangeable.
A test can also make any stray default-resource allocation fail loudly:
std::pmr::set_default_resource(std::pmr::null_memory_resource()) for the
duration of the test (restore the old one afterwards).
Summary
- Containers allocate through
allocator_traits; the allocator is part of the container's type. Equal allocators can free each other's memory. - Propagation traits decide what assignment and swap do with allocators. With unequal, non-propagating allocators, move assignment copies element-wise.
std::pmrputs the strategy behind amemory_resource*; one container type, many resources. pmr allocators never propagate, and copies use the default resource.- Nested pmr types inherit the container's resource via uses-allocator construction — but temporaries you create use the default resource.
monotonic_buffer_resource+ a stack buffer +null_memory_resource()upstream = a heap-free arena; pools for long-lived churn.- Resources must outlive their containers; copy results out before
release().