CodeRigor

FREE LESSON

Allocators and std::pmr

Control where containers get their memory: the allocator model, propagation traits, polymorphic memory resources, arenas, and the lifetime rules that keep them safe.

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:

C++
#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> and std::vector<int, Mallocator<int>> are different types: you can't pass one where the other is expected.
  • Equality means interchangeability. a == b promises that memory from a can be freed by b. 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.

C++
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;
};
}
One allocator type, many strategies behind a pointer
Every pmr container holds a polymorphic_allocator, which holds a memory_resource*. Which resource it points at is a runtime choice; the container's type never changes.
One allocator type, many strategies behind a pointer
Every pmr container holds a 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.

C++
#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);
}
Uses-allocator construction puts the nested strings in the arena too
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.
Uses-allocator construction puts the nested strings in the arena too
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:

C++
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 directly

The 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:

C++
#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();
}
monotonic_buffer_resource: a pointer that only moves forward
start / 5
Each cell is 16 bytes of the 4 KiB frame (first 192 B shown). Step through: allocations bump next; deallocate does nothing; release() resets everything at once.
monotonic_buffer_resource: a pointer that only moves forward
Each cell is 16 bytes of the 4 KiB frame (first 192 B shown). Step through: allocations bump next; deallocate does nothing; release() resets everything at once.

Three design choices carry the example:

  1. The buffer is on the stack: no heap at all for small requests.
  2. The upstream is null_memory_resource(): if the buffer runs out, allocation throws std::bad_alloc instead of quietly using the heap. Use new_delete_resource() instead if overflow is acceptable but should be rare.
  3. 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?

C++
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:

A pool resource: fixed-size blocks per size class, freed blocks reused
Requests are rounded up to a size class. Freeing a block puts it on its class's free list; the next request of that size takes it back — so churn doesn't grow memory.
A pool resource: fixed-size blocks per size class, freed blocks reused
Requests are rounded up to a size class. Freeing a block puts it on its class's free list; the next request of that size takes it back — so churn doesn't grow memory.
  • 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:

C++
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.

C++
#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::pmr puts the strategy behind a memory_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().

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

The allocator model and C++17 polymorphic memory resources from the lead author of PMR.

Local (arena) allocators part 1: why they matter and how to measure.

Part 2: benchmarks showing monotonic/arena allocation often 10x faster.

Hands-on: pmr containers with a monotonic buffer resource.

Tracking allocations through the standard PMR resources.

Custom allocators from basics to advanced.

Unity/C#: a bump-pointer memory arena explained visually — the same idea as std::pmr::monotonic_buffer_resource.

Writing a type that takes an allocator and propagates it to its members — what makes PMR nesting work.

Where PMR helps and where it doesn't: measured results and the pitfalls of memory resources.