Chapter 46: PMR
PMR(Polymorphic Memory Resource)把 STL 容器的分配策略从模板参数转成运行时对象。读完本章后,应能定位一段代码中的资源对象、allocator 对象和容器对象,判断内存来自哪里、何时归还、容器拷贝或移动时是否沿用同一资源,以及不同 memory_resource 适合哪类对象生命周期。
普通 STL 容器已经支持 allocator 模板参数。这个设计把分配策略绑定进容器类型,例如 std::vector<T, MyAlloc<T>> 和 std::vector<T> 是不同类型。PMR 的核心变化是让容器静态类型保持为 std::pmr::vector<T>,再由其中的 std::pmr::polymorphic_allocator<T> 持有一个 std::pmr::memory_resource*。策略仍然可替换,替换点从编译期类型移动到运行时资源对象。
本章贯穿材料是一段“请求解析器”的临时对象集合。解析一个请求时,需要保存字段名、字段值和若干中间字符串;请求结束时,这些对象一起销毁。这个场景能同时覆盖 monotonic_buffer_resource、pool resource、pmr::vector、pmr::string 和工程边界,因为它的内存模式清晰:同一阶段批量创建对象,阶段结束后整体释放。
PMR 在 C++17 进入标准库,相关接口位于 <memory_resource>。本文把标准语义、常见实现形状和工程判断分开:标准规定接口与语义,具体分配块大小、增长策略细节和内部锁实现由标准库实现决定;教学代码只展示对象关系,不代表 libstdc++、libc++ 或 MSVC STL 的真实源码。
46.1 memory_resource 与 allocator 多态化
std::pmr::memory_resource 是 PMR 的运行时分配接口。它抽象出三件事:按字节数和对齐要求分配原始内存、归还原始内存、比较两个资源是否等价。标准接口见 std::pmr::memory_resource,它在 C++17 中定义为抽象基类;实际工作由派生资源覆盖 do_allocate、do_deallocate 和 do_is_equal 完成。
传统 allocator 的策略通常体现在 allocator 类型上。std::vector<int, PoolAlloc<int>> 的类型已经携带策略,函数签名、模板实例化和 ABI 边界都会受到影响。PMR 把静态 allocator 固定成 std::pmr::polymorphic_allocator<T>,allocator 内部只保存一个 memory_resource*。同一个 std::pmr::vector<int> 可以在不同实例中使用不同资源,这就是 allocator 多态化的直接效果。
贯穿材料先写成最小形状。请求对象持有两个 PMR 容器,构造时显式接收资源指针。字段名和字段值都进入同一资源,后续分析内存路径时只需追踪这个指针。
#include <memory_resource>
#include <string_view>
#include <vector>
#include <string>
struct Field {
std::pmr::string name;
std::pmr::string value;
Field(std::string_view n,
std::string_view v,
std::pmr::memory_resource* resource)
: name(n.data(), n.size(), resource),
value(v.data(), v.size(), resource) {}
};
struct RequestScratch {
std::pmr::vector<Field> fields;
explicit RequestScratch(std::pmr::memory_resource* resource)
: fields(resource) {}
void add_field(std::string_view name, std::string_view value) {
fields.emplace_back(name, value, fields.get_allocator().resource());
}
};
这段代码展示了 PMR 的两层责任。RequestScratch 和 std::pmr::vector<Field> 负责元素生命周期:构造 Field、移动元素、销毁元素。memory_resource 只负责原始内存:给出一段满足大小和对齐要求的存储,并在合适时机收回。元素析构和内存释放属于两个动作,PMR 只是改变后一个动作的来源和归还策略。
对象关系可以压缩成一条分配链:容器调用 allocator,allocator 调用资源,资源决定从本地缓冲、池、上游资源或全局 new/delete 获取内存。
图中的关键点是策略对象的寿命。容器通常只保存 allocator,allocator 保存资源指针。资源对象必须覆盖容器中所有依赖它的内存使用期;如果资源对象先销毁,容器后续析构、扩容、清空或赋值都会触碰悬垂资源指针。这个判断优先级高于性能,因为资源寿命错误会直接破坏对象销毁路径。
std::pmr::polymorphic_allocator 的文档说明它根据构造时绑定的 memory_resource 表现出不同分配行为,并且容器元素也可以通过 uses-allocator construction 使用同一资源。标准库还提供 std::pmr::get_default_resource()、std::pmr::set_default_resource() 和 std::pmr::new_delete_resource()。工程代码应优先显式传入资源,把默认资源作为边界层配置;全局默认资源会影响默认构造的 PMR allocator,适合集中配置,较少适合作为局部性能调优的隐式开关。
PMR 代码的第一组检查顺序是:先找资源对象在哪里创建,再找哪些容器保存了指向它的 allocator;随后检查容器是否可能越过资源寿命;再看元素类型是否也使用 PMR allocator;最后再讨论资源类型带来的分配次数、碎片和锁成本。
46.2 monotonic_buffer_resource
std::pmr::monotonic_buffer_resource 是单调分配资源。它把分配看成向前推进的偏移量,do_allocate 从当前缓冲区切出一段满足对齐的存储;单个 deallocate 调用通常没有实际释放效果;release() 或资源析构时统一释放已经持有的缓冲区。标准说明见 std::pmr::monotonic_buffer_resource,它适合“构建少量对象后一次性释放”的场景,并且该资源本身没有线程安全承诺。
请求解析器适合用 monotonic resource 的前提是生命周期一致。一次请求中的字段、临时 token 和中间字符串都跟随请求结束而销毁。分配阶段频繁,释放阶段集中。资源无需维护每个小对象的空闲块链表,分配路径短,分支少,局部性也更容易稳定。
#include <array>
#include <cstddef>
#include <memory_resource>
void parse_one_request(std::string_view raw) {
std::array<std::byte, 4096> buffer{};
std::pmr::monotonic_buffer_resource arena{
buffer.data(),
buffer.size(),
std::pmr::new_delete_resource()
};
RequestScratch scratch{&arena};
scratch.add_field("method", "GET");
scratch.add_field("path", raw);
// 使用 scratch 完成解析、校验和路由匹配。
}
这个例子要证明三个对象边界。第一,buffer 是初始原始存储,能够覆盖小请求时的多数分配。第二,arena 是资源对象,它在初始缓冲耗尽时向上游资源继续申请更大的缓冲。第三,scratch 中的容器和字符串必须先于 arena 结束使用;当前局部变量的逆序析构满足这个条件,scratch 先析构,arena 再释放缓冲。
release() 是 monotonic resource 的显式阶段结束操作。调用它会释放资源已经取得的所有缓冲,并让资源回到可重新分配的状态。它适合在一个 arena 被循环复用时使用,例如每轮处理一个请求,先销毁本轮容器,再调用 release() 重置资源。调用顺序必须让容器和对象先析构,因为资源只知道原始内存,不负责替仍然存活的对象调用析构函数。
void parse_many_requests(const std::string_view* first,
const std::string_view* last) {
std::array<std::byte, 8192> buffer{};
std::pmr::monotonic_buffer_resource arena{buffer.data(), buffer.size()};
for (auto it = first; it != last; ++it) {
std::string_view raw = *it;
{
RequestScratch scratch{&arena};
scratch.add_field("path", raw);
// 使用 scratch 完成本轮处理。
}
arena.release();
}
}
这段循环中的作用域就是安全边界。scratch 的析构关闭元素生命周期,arena.release() 关闭原始存储生命周期。顺序调换后,容器析构会访问已经被资源释放的存储,代码形状上仍然能编译,运行时对象关系已经失效。
monotonic resource 的性能收益来自释放模式匹配。它适合 append-heavy、批量销毁、临时工作区、编译器中间表示、请求上下文和一次性构建的数据结构。它不适合长期在线对象的随机删除,也不适合容器频繁缩容后还要把内存还给系统的场景。单个对象删除时,资源通常保留整块缓冲,内存占用会跟随阶段峰值保留到 release() 或析构。
工程上判断 monotonic resource 时先看阶段边界:对象是否都在同一阶段结束;再看峰值大小是否可估;再看初始缓冲能否覆盖常见路径;最后看上游资源是否合适。初始缓冲过小会频繁进入上游资源,收益会下降;初始缓冲过大会增加每个 arena 的常驻内存成本。
46.3 pool resource 系列
pool resource 适合“对象大小集中、分配和归还有交错、同类块会复用”的内存模式。标准库提供 std::pmr::unsynchronized_pool_resource 和 std::pmr::synchronized_pool_resource。二者都从上游资源取得 chunk,再把 chunk 切成不同大小类别的 block;小块请求进入对应 pool,大块请求可能直接走上游资源。std::pmr::pool_options 可以调节最大块大小和最大 chunk 大小。
unsynchronized_pool_resource 的标准说明见 std::pmr::unsynchronized_pool_resource。它拥有已分配内存,析构时释放;它维护多个 pool,每个 pool 服务一种块大小范围;它没有线程安全承诺。单线程解析器、单线程对象缓存、线程局部工作区通常优先考虑这个资源。
synchronized_pool_resource 的标准说明见 std::pmr::synchronized_pool_resource。它可以被多个线程访问,并且实现可以使用线程特定 pool 降低同步成本。同步能力带来锁、原子操作或内部分流的成本;当资源只被一个线程访问时,非同步版本通常更直接。
请求解析器如果从“整批销毁”变成“字段对象会在处理过程中增删”,pool resource 的匹配度会上升。比如解析器先接收一批 header,再根据规则删除、补充、归一化,随后继续保留部分字段进入下游模块。对象大小大致集中在 Field、字符串短缓冲外溢块和 vector 扩容块,资源可以复用归还的块。
#include <memory_resource>
void normalize_request(std::string_view raw) {
std::pmr::unsynchronized_pool_resource pool;
RequestScratch scratch{&pool};
scratch.add_field("path", raw);
scratch.add_field("cache-control", "no-cache");
// 处理中可能删除旧字段、插入新字段,归还的小块可被 pool 复用。
}
pool resource 的 deallocate 具有实际意义:块可以回到对应 pool,之后同类大小请求可复用它。这个路径和 monotonic resource 形成清晰对比:monotonic resource 用阶段结束统一释放换取更短路径,pool resource 用空闲块管理支持局部归还和复用。
两类 pool resource 的并发边界应按资源对象访问方式判断,而非按容器类型判断。多个线程各自拥有独立资源对象时,可以使用多个 unsynchronized_pool_resource。多个线程共享同一个资源对象,并且它们的容器会同时分配或归还内存时,应使用 synchronized_pool_resource 或在资源外层提供同步。容器自身的并发规则仍然独立存在;资源线程安全只覆盖资源对象的分配接口,不自动让同一个容器支持并发修改。
pool resource 的碎片边界来自块大小分组。小块按大小类别复用,能减少频繁系统分配;但请求大小分布跨度很大时,某些 pool 可能保留很多难以复用的 chunk。长期运行服务中,pool 资源的生命周期如果过长,它可能记住历史峰值,导致内存长期维持在峰值附近。此时可以按连接、请求批次、线程或任务阶段切分资源寿命,让 pool 的持有范围匹配真实复用范围。
选择 pool resource 的检查顺序是:先看对象是否有交错释放;再看分配尺寸是否集中;再看资源是否跨线程共享;随后看资源寿命是否会覆盖过多业务阶段;最后再调 pool_options。调参数之前应先让资源边界正确,因为错误边界会把局部池变成全局内存仓库,性能数据也会失去解释力。
46.4 pmr::vector 与 pmr::string
std::pmr::vector<T> 是 std::vector<T, std::pmr::polymorphic_allocator<T>> 的别名;std::pmr::string 是使用 std::pmr::polymorphic_allocator<char> 的 std::basic_string 别名。它们仍然是标准容器和标准字符串,元素所有权、连续存储、扩容、迭代器失效、异常安全承诺和字符串接口语义仍沿用对应容器规则。PMR 改变的是分配来源和 allocator 传播相关边界。
std::pmr::vector 的连续存储模型没有变化。push_back、emplace_back、reserve 等操作仍可能触发扩容,扩容仍会申请新连续存储、构造或移动元素、销毁旧元素、归还旧存储。不同点在于“申请”和“归还”通过绑定的 memory_resource 完成。vector::data() 仍返回连续元素存储;标准 std::vector 的连续性和 data() 接口可见于 std::vector。
std::pmr::string 的核心状态也没有变化。它仍然管理字符序列、长度和容量,可能拥有短字符串优化(SSO)这类实现策略。SSO 是实现细节:短字符串可能不向资源分配,较长字符串或增长后才会进入资源。工程上不能把“使用了 pmr::string”等同于“每个字符串都经过资源对象”。可观察判断应看字符串长度、实现策略和分配统计。
下面的例子把 pmr::vector 和 pmr::string 放在同一资源中。它展示的重点是嵌套分配传播:vector 的元素是 PMR string 时,元素内部字符分配也能使用同一资源。
#include <array>
#include <cstddef>
#include <memory_resource>
#include <string>
#include <vector>
std::pmr::vector<std::pmr::string>
make_tokens(std::string_view line, std::pmr::memory_resource* resource) {
std::pmr::vector<std::pmr::string> tokens{resource};
tokens.emplace_back(line.substr(0, 4), resource);
tokens.emplace_back(line.substr(5), resource);
return tokens;
}
void use_tokens(std::string_view line) {
std::array<std::byte, 2048> buffer{};
std::pmr::monotonic_buffer_resource arena{buffer.data(), buffer.size()};
auto tokens = make_tokens(line, &arena);
// tokens 的 vector 存储和长字符串字符存储都指向 arena。
}
这里显式给 pmr::string 传入 resource,是为了让示例中的资源流向一眼可见。实际写 std::pmr::vector<std::pmr::string> tokens{resource}; tokens.emplace_back(...); 时,std::pmr::polymorphic_allocator 的 uses-allocator construction 也会把同一资源传给元素;标准说明中给出的例子正是 std::pmr::vector<std::pmr::string> 的 vector 存储和 string 存储使用同一 memory_resource。
PMR 容器的拷贝、移动和交换需要单独检查 allocator 关系。std::pmr::polymorphic_allocator 的说明指出,它在容器 copy assignment、move assignment 和 swap 中不传播;如果两个使用 PMR allocator 的容器 allocator 比较不相等,交换它们会形成未定义行为,move assignment 也可能抛出。这是 PMR 章节中最容易被忽略的工程边界:资源对象变成运行时选择后,两个容器静态类型相同,运行时资源仍可能不同。
void resource_boundary() {
std::pmr::monotonic_buffer_resource left_arena;
std::pmr::monotonic_buffer_resource right_arena;
std::pmr::vector<std::pmr::string> left{&left_arena};
std::pmr::vector<std::pmr::string> right{&right_arena};
left.emplace_back("left");
right.emplace_back("right");
// 交换、赋值和跨模块返回前,先比较 allocator.resource() 的关系。
const bool same_resource =
left.get_allocator().resource()->is_equal(*right.get_allocator().resource());
(void)same_resource;
}
这段代码不执行交换,只展示应检查的证据。两个容器都是 std::pmr::vector<std::pmr::string>,类型层面已经看不出差异;get_allocator().resource() 才能显示运行时资源关系。跨模块 API 如果接收或返回 PMR 容器,应把资源约定写入接口语义:调用方提供资源、被调用方只在调用期使用资源、返回对象必须携带仍然有效的资源,或者返回前复制到调用方指定资源中。
pmr::vector 与 pmr::string 的判断顺序是:先保持容器语义不变,继续按 vector 或 string 的规则判断所有权、扩容、失效和复杂度;再追踪 allocator 的资源指针;随后检查嵌套对象是否也走 PMR;最后检查拷贝、移动、交换和返回值是否跨越资源边界。
46.5 工程使用场景
PMR 的工程价值来自“把内存策略变成可注入资源”。当一个模块需要在普通堆、请求 arena、线程局部池、插件提供的资源之间切换,并且希望容器类型保持稳定时,PMR 比自定义 allocator 模板参数更适合。它把策略选择推迟到运行时,也把资源寿命管理暴露给工程代码。
短生命周期批量对象是最典型场景。请求解析、编译器语法树局部阶段、日志格式化缓冲、协议解码、一次性 JSON 或 AST 构建,都有明确阶段边界。此时可用 monotonic_buffer_resource 承接大量小分配,在阶段结束后统一 release() 或销毁资源。判断重点是对象是否全部跟随阶段结束,以及峰值内存是否可接受。
arena 场景强调所有权边界。arena 负责原始存储的阶段管理,容器负责元素生命周期。代码设计应让 arena 的创建点、使用点和释放点可见。推荐把 std::pmr::memory_resource* 作为工作区参数传入,或把它封装在 request context、compilation context、format context 中。这样阅读者能从接口看出“这些对象共享同一资源”。
插件边界适合 PMR,但接口约定要清楚。宿主程序可以向插件传入 memory_resource*,插件内部使用 pmr::vector、pmr::string 构造结果。结果如果返回宿主继续持有,资源对象必须由宿主保持存活;结果如果只在插件调用期间有效,接口应写成回调消费或 view 消费。PMR 解决分配策略注入问题,资源寿命仍由 API 设计承担。
性能调优场景应先收集证据。PMR 常见收益来自减少系统分配次数、改善小对象局部性、缩短释放路径、降低 allocator 类型扩散。它也会引入资源对象间接调用、同步资源锁成本、池内保留内存和阶段峰值保留。工程判断应以分配热点、对象大小分布、线程访问方式和生命周期数据为依据。
下面这组选择规则可以作为 PMR 落地顺序。
- 阶段统一释放:优先考虑
monotonic_buffer_resource,并把资源寿命绑定到请求、任务或批次。 - 小块频繁复用:优先考虑 pool resource,先选同步边界,再调
pool_options。 - 跨线程共享资源:使用
synchronized_pool_resource或外部同步;单线程资源优先使用非同步版本。 - 普通兼容路径:使用
new_delete_resource()或默认资源,保持行为接近普通堆分配。 - 测试分配边界:使用自定义
memory_resource记录分配次数、字节数和调用位置,用数据确认热点。
自定义资源通常用于观测、限制和接入专用内存系统。教学版记录资源可以这样写:
#include <cstddef>
#include <memory_resource>
class CountingResource : public std::pmr::memory_resource {
public:
explicit CountingResource(std::pmr::memory_resource* upstream)
: upstream_(upstream) {}
std::size_t allocated_bytes() const noexcept {
return allocated_bytes_;
}
private:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
allocated_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_{};
std::size_t allocated_bytes_{};
};
这个资源只适合作为观测示例。它统计申请字节数,真正分配仍委托给上游资源。实际工程中还要考虑线程安全、计数溢出、异常路径和日志开销。示例的价值在于显示 PMR 的扩展点:容器完全不知道统计逻辑,只看到标准 memory_resource 接口。
PMR 的最终判断顺序可以固定为五步:第一,确认对象生命周期是否有明确阶段;第二,确认分配大小分布和释放模式;第三,选择 monotonic、pool、同步 pool 或普通堆资源;第四,检查资源对象寿命是否覆盖所有 PMR 容器和嵌套 PMR 对象;第五,检查拷贝、移动、交换和跨模块返回是否跨越资源边界。这个顺序比直接替换容器类型更可靠,因为 PMR 的风险主要来自资源寿命和传播边界。
最小自检任务
阅读下面代码,判断 tokens、copied 和 moved 的内存资源关系,并说明这段代码在工程上需要检查哪些边界。
#include <array>
#include <cstddef>
#include <memory_resource>
#include <string>
#include <vector>
std::pmr::vector<std::pmr::string>
make_words(std::pmr::memory_resource* resource) {
std::pmr::vector<std::pmr::string> words{resource};
words.emplace_back("alpha");
words.emplace_back("beta-beta-beta-beta-beta");
return words;
}
void check_words() {
std::array<std::byte, 4096> buffer{};
std::pmr::monotonic_buffer_resource arena{buffer.data(), buffer.size()};
auto tokens = make_words(&arena);
auto copied = tokens;
auto moved = std::move(tokens);
(void)copied;
(void)moved;
}
答案要点
tokens 由 make_words(&arena) 构造,vector 的连续存储使用 arena,其中较长 pmr::string 的字符存储也会通过 PMR allocator 使用同一资源;短字符串是否向资源申请内存取决于标准库实现的短字符串优化和字符串长度。
copied = tokens 是容器拷贝构造。标准容器拷贝构造会通过 allocator traits 取得拷贝构造用 allocator;std::pmr::polymorphic_allocator 的该选择会创建一个默认构造的 PMR allocator,也就是使用当前 get_default_resource()。因此 copied 的资源按默认资源判断,通常不同于 arena,除非默认资源被设置成同一个资源。工程代码如果要求拷贝结果使用某个资源,应使用带 allocator 的构造方式或把资源约定写入接口。
moved = std::move(tokens) 是移动构造。无显式 allocator 的移动构造会随源容器取得 allocator,因此 moved 继续持有指向 arena 的资源关系,并且通常可以接管原容器存储。移动后 tokens 保持可析构、可重新赋值的有效状态;后续交换、移动赋值和拷贝赋值还要检查 allocator 是否相等,以及资源对象是否仍然存活。
这段代码中 arena 位于 check_words 栈帧内,moved 和移动后的 tokens 都在 arena 析构前结束生命周期,资源寿命顺序成立。copied 按默认资源判断,它的资源寿命还要按默认资源单独检查。工程边界集中在三处:嵌套 pmr::string 是否沿用资源,拷贝结果是否使用预期资源,移动和交换是否跨越不相等资源。
本章知识点总结
- PMR 核心:PMR 把分配策略从 allocator 模板类型转成运行时
memory_resource对象。 - 资源接口:
memory_resource负责原始内存分配、归还和资源等价比较。 - 容器责任:PMR 容器仍负责元素构造、移动、销毁、失效规则和复杂度承诺。
- 寿命优先:资源对象寿命必须覆盖所有持有其 allocator 的容器和嵌套对象。
- 单调资源:
monotonic_buffer_resource适合阶段性批量分配和整体释放。 - 释放顺序:使用
release()前应先结束依赖该资源的对象生命周期。 - 池化资源:pool resource 适合小块频繁复用和交错归还的分配模式。
- 同步边界:
unsynchronized_pool_resource面向单线程资源访问,synchronized_pool_resource面向共享资源访问。 - PMR vector:
pmr::vector的连续存储和迭代器失效规则仍按vector判断。 - PMR string:
pmr::string的字符存储可能使用资源,短字符串优化会影响实际分配。 - 嵌套传播:
std::pmr::vector<std::pmr::string>可以让 vector 存储和 string 存储使用同一资源。 - 赋值边界:PMR allocator 在赋值和交换中的传播规则需要单独检查。
- 工程选型:先看生命周期和释放模式,再选 monotonic、pool、同步 pool 或普通堆资源。
- 调优证据:PMR 性能判断应依赖分配次数、块大小分布、线程访问方式和峰值内存数据。