Allocator 是 STL 容器与底层内存系统之间的抽象层:容器只负责对象构造与数据结构逻辑,内存的获取与归还交给 allocator。默认的 std::allocator 覆盖了绝大多数场景,但在高频分配、生命周期集中或需要内存审计的场景下,理解并定制这一层是最后的性能杠杆。本文梳理 allocator 的接口契约、自定义要点与 C++17 引入的 PMR,并用实测数据检验常见的性能假设。
接口契约
C++11 起,一个 allocator 的最小要求只有四个要素:
value_type:元素类型别名;allocate(n):分配足以容纳 n 个 value_type 的未构造内存,失败抛 std::bad_alloc;deallocate(p, n):归还 allocate 返回的内存,n 必须与分配时一致;operator==:判定两个 allocator 实例是否可以互相释放内存。
其余成员——pointer、rebind、construct、destroy——都由 allocator_traits 按需补全:rebind 通过模板形参模式自动推导,对象构造则直接落到 std::construct_at。std::allocator 的 construct/destroy 成员在 C++17 弃用、C++20 移除,自定义 allocator 几乎不需要插手对象生命周期。
一个无状态、可全局统计的计数 allocator 只需要这么多代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| #include <cstddef> #include <cstdio> #include <new> #include <vector>
template <typename T> class CountingAllocator { public: using value_type = T;
T* allocate(std::size_t n) { if (n > SIZE_MAX / sizeof(T)) { throw std::bad_alloc{}; } ++live_allocations; return static_cast<T*>( ::operator new(n * sizeof(T))); }
void deallocate(T* p, std::size_t) noexcept { --live_allocations; ::operator delete(p); }
friend bool operator==( const CountingAllocator&, const CountingAllocator&) noexcept { return true; }
static inline int live_allocations = 0; };
std::vector<int, CountingAllocator<int>> vec; vec.push_back(42); printf("live = %d\n", CountingAllocator<int>::live_allocations);
|
C++20 起 operator!= 可由 == 自动合成;更老的标准需要手写一份。
相等性与传播
Allocator 分两类。无状态(stateless)allocator 如 std::allocator,所有实例等价,operator== 恒为 true。有状态(stateful)allocator 携带数据——内存池、资源指针、统计计数——实例之间是否相等必须显式定义,语义是"两个实例可以互相释放对方的内存"。相等性判断错了,容器行为直接未定义。
容器在拷贝赋值、移动赋值和交换时如何对待 allocator,由三个传播 trait 控制,外加一个相等性声明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| template <typename T> class MyAllocator { public: using value_type = T;
using propagate_on_container_copy_assignment = std::true_type;
using propagate_on_container_move_assignment = std::true_type;
using propagate_on_container_swap = std::true_type;
using is_always_equal = std::true_type; };
|
默认值全部为 false_type。此时拷贝赋值后目标容器保留自己的 allocator;而交换是硬约束——propagate_on_container_swap 为 false 时,交换两个 allocator 不相等的容器是未定义行为,标准连兜底语义都不给。
手写一个 Arena Allocator
Arena(区域分配器)从预分配的大块内存中线性分配、整体释放,适合游戏引擎、编译器、请求处理这类大量短命对象集中出现的场景。它也是暴露 stateful allocator 全部难点的好例子。
难点在所有权:allocator 会被容器拷贝(std::list 还会 rebind 出节点类型的 allocator),如果 allocator 直接持有缓冲区,默认拷贝会导致双重释放。正确的结构是 Arena 拥有内存、allocator 只持观察指针:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| #include <cstddef> #include <cstdint> #include <memory> #include <new> #include <vector>
class Arena { public: explicit Arena(std::size_t bytes) : buffer_(static_cast<std::byte*>( ::operator new(bytes))) , cursor_(buffer_) , end_(buffer_ + bytes) {}
~Arena() { ::operator delete(buffer_); }
Arena(const Arena&) = delete; Arena& operator=(const Arena&) = delete;
void* allocate(std::size_t bytes, std::size_t alignment) { auto addr = reinterpret_cast<std::uintptr_t>( cursor_); std::size_t padding = (alignment - addr % alignment) % alignment; if (padding + bytes > static_cast<std::size_t>(end_ - cursor_)) { throw std::bad_alloc{}; } cursor_ += padding + bytes; return cursor_ - bytes; }
void reset() noexcept { cursor_ = buffer_; }
private: std::byte* buffer_; std::byte* cursor_; std::byte* end_; };
template <typename T> class ArenaAllocator { public: using value_type = T;
explicit ArenaAllocator( std::shared_ptr<Arena> arena) noexcept : arena_(std::move(arena)) {}
template <typename U> ArenaAllocator(const ArenaAllocator<U>& other) : arena_(other.arena_) {}
T* allocate(std::size_t n) { return static_cast<T*>(arena_->allocate( n * sizeof(T), alignof(T))); }
void deallocate(T*, std::size_t) noexcept { }
template <typename U> bool operator==( const ArenaAllocator<U>& rhs) const noexcept { return arena_ == rhs.arena_; }
private: template <typename U> friend class ArenaAllocator;
std::shared_ptr<Arena> arena_; };
int main() { auto arena = std::make_shared<Arena>(64 * 1024); std::vector<int, ArenaAllocator<int>> vec{ ArenaAllocator<int>{arena}};
for (int i = 0; i < 100; ++i) { vec.push_back(i); } }
|
这个设计逐条回应了 stateful allocator 的契约:shared_ptr 让拷贝共享同一块 Arena,析构安全;跨类型的 operator== 比较 Arena 指针,同一区域的两个 allocator 视为相等,容器在 rebind 出的节点 allocator 之间转移内存没有障碍;deallocate 是空操作,意味着内存要等 Arena 析构或 reset() 才真正归还——vector 扩容时旧缓冲区不释放,用空间换时间,这正是 Arena 的本意。如果业务需要交换容器,还要把 propagate_on_container_swap 声明为 std::true_type。
写到这里可以看出问题:所有权、相等性、rebind、传播策略,每一环都要自己扛。这就是 PMR 被发明出来的原因。
PMR:多态内存资源
C++17 的 PMR(Polymorphic Memory Resources)把分配策略从模板参数移到运行时。std::pmr::memory_resource 提供分配、释放和相等性虚接口;std::pmr::polymorphic_allocator<T> 只保存一个资源指针。因此,容器可在构造时选择资源,不必为每种策略生成一套容器类型。
| 资源 | 适用场景 | 特性 |
|---|
monotonic_buffer_resource | 一批短命对象 | 只分配、不单独释放,整体回收 |
unsynchronized_pool_resource | 单线程、反复分配小块 | 池化复用 |
synchronized_pool_resource | 多线程共享资源 | 池化,带同步开销 |
new_delete_resource() | 默认上游 | 直通全局 new/delete |
资源可指定上游:当前资源耗尽或需要取得新块时,向上游申请。
一次性生命周期:Monotonic Buffer
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #include <cstddef> #include <memory_resource> #include <vector>
int main() { std::byte buffer[1024]; std::pmr::monotonic_buffer_resource r{ buffer, sizeof(buffer)};
std::pmr::vector<int> vec{&r}; for (int i = 0; i < 100; ++i) { vec.push_back(i * 2); } }
|
缓冲耗尽后会使用默认上游 new_delete_resource();vec 析构不会归还单块内存,只有 r.release() 或 r 析构时才整体回收。资源必须比所有使用它的对象活得久。若元素自身还会动态分配,例如字符串,应使用 std::pmr::vector<std::pmr::string>,让元素的内部内存也走同一资源。
性能:测量优先
"PMR 一定更快"是值得怀疑的假设。以下基准用 Google Benchmark、-O2 优化,测量向容器填充 n 个字符串的总耗时:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| #include <benchmark/benchmark.h> #include <cstdint> #include <memory_resource> #include <string> #include <vector>
static void BM_Default(benchmark::State& state) { for (auto _ : state) { std::vector<std::string> vec; for (std::int64_t i = 0; i < state.range(0); ++i) { vec.emplace_back( "test string " + std::to_string(i)); } benchmark::DoNotOptimize(vec.data()); } }
static void BM_Monotonic( benchmark::State& state) { static std::byte buffer[2 * 1024 * 1024]; std::pmr::monotonic_buffer_resource r( buffer, sizeof(buffer)); for (auto _ : state) { std::pmr::vector<std::pmr::string> vec{&r}; for (std::int64_t i = 0; i < state.range(0); ++i) { vec.emplace_back( "test string " + std::to_string(i)); } benchmark::DoNotOptimize(vec.data()); r.release(); } }
static void BM_Pool(benchmark::State& state) { std::pmr::synchronized_pool_resource pool; for (auto _ : state) { std::pmr::vector<std::pmr::string> vec{ &pool}; for (std::int64_t i = 0; i < state.range(0); ++i) { vec.emplace_back( "test string " + std::to_string(i)); } benchmark::DoNotOptimize(vec.data()); } }
BENCHMARK(BM_Default)->Range(100, 10000); BENCHMARK(BM_Monotonic)->Range(100, 10000); BENCHMARK(BM_Pool)->Range(100, 10000);
|
一组单机实测样本(只看趋势,不当作通例),glibc 默认 malloc 下相对 std::allocator 的耗时比:
| 元素数 | monotonic | pool |
|---|
| 100–512 | ≈1.0–1.03x | ≈1.16x |
| 4096 | 快约 1.6 倍 | ≈1.09x |
| 10000 | 快约 2.2 倍 | 慢约 1.4 倍 |
换用 jemalloc(LD_PRELOAD 或链接 -ljemalloc)后格局反转:std::allocator 提速最明显,PMR 反而相对落后——monotonic 慢 10–20%,pool 慢 20–110%。jemalloc 本身就是高质量的通用分配器,默认路径已经很快,PMR 的多态分发与池管理开销在对比中被放大。
这组数据能推出的结论:
- malloc 越弱,绕开它的收益越大。分配器质量高时,定制策略的边际收益趋近于零。
- monotonic 的赢面在海量短命对象加一次性释放,且大块复用时分配成本趋近于零。
- pool 需要稳定的分配模式才能体现块复用价值,中等规模下与默认打平是常态。
- PMR 的核心价值是多态与生命周期控制,不是裸性能。
与智能指针配合
std::allocate_shared 可以让控制块和对象一起从指定 allocator 分配,但这恰恰放大了资源生命周期问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| #include <memory> #include <memory_resource> #include <print>
struct Widget { int data; };
std::shared_ptr<Widget> make_widget_bad() { std::pmr::monotonic_buffer_resource r{1024}; std::pmr::polymorphic_allocator<Widget> alloc{ &r}; return std::allocate_shared<Widget>(alloc, 42); }
std::shared_ptr<Widget> make_widget( std::pmr::monotonic_buffer_resource& r) { std::pmr::polymorphic_allocator<Widget> alloc{ &r}; return std::allocate_shared<Widget>(alloc, 42); }
|
C++20 另外补齐了 uses-allocator 构造的工具:std::make_obj_using_allocator<T>(alloc, args...) 与 std::uses_allocator_construction_args,让任意类型在构造时接收 allocator,不必手写转发样板。
标准演进速览
| 标准 | 变化 |
|---|
| C++11 | allocator_traits 与最小 allocator 要求,旧式接口全面收编 |
| C++17 | PMR(memory_resource、polymorphic_allocator、内置资源);std::allocator 的 construct/destroy 弃用 |
| C++20 | 上述弃用成员移除;polymorphic_allocator 增加 allocate_bytes/new_object 系列;aligned new 全面可用 |
| C++23 | allocator_traits::allocate_at_least 尺寸反馈,分配器可返回实际容量供 sized deallocation 使用 |
陷阱清单
陷阱 1:交换不相等的 stateful allocator。 默认传播策略下,交换两个 allocator 不相等的容器是未定义行为:
1 2 3 4
| std::pmr::monotonic_buffer_resource r1, r2; std::pmr::vector<int> a{&r1}, b{&r2};
std::swap(a, b);
|
解法要么声明 propagate_on_container_swap,要么保证资源相等(同一 memory_resource*)。
陷阱 2:资源悬垂。 由 memory_resource 分配的对象活得比资源本身久——典型如前文的 make_widget_bad。规则只有一条:资源生命周期必须覆盖所有由它分配的对象。用 AddressSanitizer(clang++ -fsanitize=address)一查一个准。
陷阱 3:元素不走你的资源。 std::pmr::vector<std::string> 只让容器本身的缓冲走 PMR,std::string 元素的堆分配仍然走全局 new。想要整条链路统一,元素也得用 std::pmr::string。
陷阱 4:类型边界。 std::pmr::vector<int> 与 std::vector<int> 是不同类型,无法直接传给接收 std::vector<int>& 的接口,跨 ABI 边界(插件、动态库)更是不能混用。PMR 适合留在模块内部。
陷阱 5:对齐。 ::operator new 只保证 __STDCPP_DEFAULT_NEW_ALIGNMENT__(通常 16 字节),对 over-aligned 类型(如 alignas(32) 的 SIMD 结构)必须走 aligned new:
1 2 3 4 5 6 7 8 9 10 11 12
| T* allocate(std::size_t n) { return static_cast<T*>( ::operator new(n * sizeof(T))); }
T* allocate(std::size_t n) { return static_cast<T*>(::operator new( n * sizeof(T), std::align_val_t{alignof(T)})); }
|
结论
从 std::allocator 到手写 arena,allocator 的谱系跨度很大,但决策路径是清晰的:
- 默认优先:99% 的场景
std::allocator 加一个高质量全局分配器(jemalloc、mimalloc)已经够用,LD_PRELOAD 就能验证。 - 测量给定制发资格证:profiler 证明分配是热点,才轮到 allocator 出场。
- 定制优先选 PMR:所有权、相等性、传播这些难题标准库都替你解决了。
- 手写 allocator 只留给模板场景:编译期注入、类型级特化这类必须出现在类型系统里的需求。
- 一切结论以自己的基准为准:本文的数据也只代表那台机器上的那次运行。
简单第一。解决真问题,别追虚幻性能。