返回专辑
·Johan·9 分钟阅读

std::pmr 帧级内存池:释放快不等于对象能跨帧活

用 monotonic_buffer_resource 管理帧级临时对象,讲清 memory_resource、upstream、allocator 传播、release 后悬垂、线程边界,以及无堆分配测试与基准方法。

std::pmr 帧级内存池:释放快不等于对象能跨帧活

一帧点云处理会产生候选索引、体素键、临时字符串和许多小向量。它们在帧尾一起失效,却各自通过 new/delete 往返通用堆。std::pmr::monotonic_buffer_resource(下文简称 MBR)适合这种“成批出生、成批死亡”的数据:分配通常只推进游标,单个 deallocate 不回收,release() 或资源析构会一次性使该资源先前给出的分配全部失效,并向 upstream 归还后续申请的块。调用方提供的初始缓冲始终由调用方拥有;MBR 只重置对它的使用状态,不会释放这块外部存储。

但释放快不提供生命周期魔法。任何容器、string_view、裸指针或闭包,只要引用 arena 存储,在 release() 后都悬垂。更隐蔽的是,仍存活的 vector 会保留已经失效的 data()capacity();下一帧 push_back 可能不再调用 allocator,而是直接写入旧地址。帧级内存池的核心契约是:对象生命周期必须严格嵌套在资源有效期内。

1. memory_resource 管字节,不拥有对象

std::pmr::memory_resource 的协议只有按字节数和对齐分配、归还分配块、判断资源是否可互换。std::pmr::polymorphic_allocator<T> 持有一个 resource 指针,std::pmr::vectorstd::pmr::string 只是使用该 allocator 的标准容器别名。资源指针不拥有资源,resource 也不知道其字节中构造了什么对象。

cpp
std::pmr::vector<int> broken() {
  std::byte storage[4096];
  std::pmr::monotonic_buffer_resource arena{
      storage, sizeof storage, std::pmr::null_memory_resource()};
  std::pmr::vector<int> values{&arena};
  values.push_back(42);
  return values;  // data 和 allocator 都依赖即将死亡的局部对象
}

即使返回时发生 move,容器仍可能带着局部 arena 指针;NRVO 也不会延长 storagearena 的生命。MBR 的资源相等性采用对象身份:只有同一个 MBR 对象与自身 is_equal,两个不同的 MBR 对象即使类型和 upstream 都相同也不相等。memory_resource 的相等语义表示两边分配的存储可以由对方归还,不能仅凭资源类型或配置自行推断。容器能否偷取缓冲、交换或逐元素移动,取决于这个相等性判断和 allocator 传播规则。

默认资源是进程级隐式状态。低延迟模块更适合显式传入 memory_resource*:调用方能看见对象归属,测试也能替换成计数或失败资源。resource 析构或 release() 只回收字节,不会替放在其中的业务对象执行析构。

2. MBR、初始缓冲与 upstream

MBR 在当前块内按 alignment 对齐并向前切空间。deallocate 是空操作,所以 vector 一帧内多次扩容后,旧缓冲也要等帧尾才回收;reserve() 仍可减少复制和峰值。MBR 不保证所有分配连续,也不能依赖其后续块的增长倍率。

cpp
alignas(std::max_align_t)
std::array<std::byte, 64 * 1024> storage;

std::pmr::monotonic_buffer_resource arena{
    storage.data(), storage.size(),
    std::pmr::null_memory_resource()};

初始缓冲耗尽时,MBR 向 upstream resource 申请新块。默认 upstream 通常最终进入通用堆,因此“有固定数组”不等于“绝不触堆”。设为 null_memory_resource() 后,溢出会抛 std::bad_alloc,适合硬预算测试。生产环境可以选择丢帧,也可允许可监控的堆回退,但不能静默接受与输入规模相关的尾延迟。

初始缓冲必须比 arena 和所有使用者活得久。它若是成员,声明顺序要保证缓冲先构造、后析构;若在栈上,任何异步任务都不能越过栈帧。调用 allocate(bytes, alignment) 时,MBR 必须返回满足该请求对齐的地址;它可以在初始字节缓冲中跳过 padding,因此缓冲声明为 alignas(std::max_align_t) 并不把后续请求限制在 max_align_t,也不保证标称容量全部可用。若扣除对齐 padding 后空间不足,分配会转向 upstream;upstream 为 null_memory_resource() 时则抛出 bad_alloc。因此 over-aligned 类型仍应专门测试,重点核对 padding 后的有效容量和溢出路径,不能只用 int 验证。

3. 正确顺序是先析构对象,再 release

最易证明正确的写法是把临时对象放入更内层作用域:

cpp
void processFrame(const Frame& frame, DurableOutput& output) {
  alignas(std::max_align_t)
  std::array<std::byte, 128 * 1024> storage;
  std::pmr::monotonic_buffer_resource arena{
      storage.data(), storage.size(),
      std::pmr::null_memory_resource()};

  {
    std::pmr::vector<Candidate> candidates{&arena};
    std::pmr::vector<std::pmr::string> labels{&arena};
    candidates.reserve(frame.expectedCandidates());
    buildCandidates(frame, candidates);
    output.copyFrom(candidates, labels);  // 拷贝到持久存储
  }                                      // 容器先析构

  arena.release();                        // 此处也可由析构代劳
}

长期持有容器再调用 clear(); arena.release(); 是错的。clear() 只析构元素,不清除 capacity;容器下一帧可能直接使用已归还的缓冲。即使元素可平凡析构,外层容器仍带有悬垂的存储状态。每帧重建 resource 和临时容器通常足够便宜,而且生命周期最清楚;若 arena 是长期成员,也必须让所有容器真正离开作用域后再 release。

非平凡元素的析构同样不能省。pmr string、句柄或业务对象可能在析构中执行逻辑,批量归还字节不会执行这些动作。不要用“反正 MBR 的 deallocate 什么也不做”推导“容器不必析构”,两者属于不同层次。

4. 跨帧的是引用,不一定是 owning 类型

常见逃逸包括 spanstring_view、Eigen Map、迭代器和 data();它们都只借用 arena。lambda 按引用捕获临时字符串并提交线程池,异步日志保存 string_view,或持久事件对象内部含一个使用 scratch resource 的 pmr string,也会在帧尾悬垂。shared_ptr 控制块活得久,不代表对象内部字符缓冲也活得久。

跨帧输出必须转换所有权:拷贝到长期 resource,或把整块 frame storage 的唯一所有权交给消费者,并在最后一个消费者结束后销毁。后一种设计要保证 resource 地址稳定;容器保存的是 resource 指针,随意 move 一个内含 arena 与缓冲的包装对象可能破坏内部引用。接口最好区分 FrameScratchPersistentResult,返回值是否允许借用一眼可见。

5. allocator 传播与嵌套字符串

std::pmr::vector<std::pmr::string> 有两层存储:vector 元素数组,以及超过 SSO 后的字符区。直接在目标容器中构造,uses_allocator 协议会把容器 allocator 传给嵌套字符串:

cpp
std::pmr::vector<std::pmr::string> names{scratch};
names.emplace_back(sensor_name.begin(), sensor_name.end());

for (const auto& name : names) {
  assert(name.get_allocator().resource() == scratch);
}

从另一个 resource move 现成字符串时,资源不相等通常需要在目标资源重新分配;短字符串又可能全在 SSO 中,使测试看不到字符分配。应使用长字符串覆盖真实路径,但不要把某个实现的 SSO 阈值写成标准契约。

copy/move assignment 不意味着目标容器从此改用源 resource;资源不等时,move 可能退化成逐元素操作。对 allocator 不相等且不传播的容器随意 swap 还可能违反前置条件。稳妥做法是显式构造到目标 resource,并检查 get_allocator().resource()。纯读取函数接收 string_view/span;需要分配返回值的函数则显式接收输出 resource,避免 allocator 选择藏在实现里。

6. 线程边界与资源选型

MBR 不提供并发分配同步。多个 worker 同时从一个 arena 构造对象会 data race。可让每个 worker 拥有独立帧 arena,或由一个线程完成分配,并保证所有只读任务 join 后才 release。thread_local 虽少传参数,却会在任务迁移、协程和线程复用时隐藏生命周期,显式 FrameContext& 更容易审查。

synchronized_pool_resource 提供并发池,却不是“线程安全版 MBR”:pool 回收并复用大小类,适合交错死亡的小对象;MBR 适合统一帧尾死亡。unsynchronized_pool_resource 也不能跨线程共享。选型依据是生命周期形状,不是名字中是否有 pool。

资源线程安全也不代表容器线程安全;两个线程同时修改同一个 vector 仍然错误。反之,每线程 arena 也不能让生产者创建的 string 在 release 后被消费者读取。跨线程队列必须持有持久副本或整个 frame storage 的所有权,释放线程还要符合自定义 upstream 的线程亲和契约。

7. 无堆分配测试与容量测量

要证明 PMR 路径不回退到堆,可用固定缓冲加 null_memory_resource() 执行完整热路径;任何 upstream 溢出都会抛 bad_alloc。同时测试刚超过预算的输入,确认系统丢帧或降级,而不是吞异常后发布半成品。

这只能覆盖通过 arena 的分配。普通 string/vector、std::function 大捕获、日志和第三方库仍可能直接访问全局堆。若目标是整帧零堆分配,应在独立测试进程中拦截全局 operator new 或使用分配跟踪器,在热区开始后计数;线程启动、locale 和测试框架的一次性分配要移出窗口。

缓冲容量应由数据而不是直觉决定。将自定义计数 resource 放在 MBR 后面,可统计初始缓冲溢出时向 upstream 申请的次数和字节;放在容器与 MBR 之间,则统计容器请求。两种布置回答不同问题。按普通帧、密集点云、重定位和长标签场景记录峰值,并覆盖 over-aligned 类型。

计数器用于多线程时也必须同步或线程隔离。建议测试正常负载无 upstream、声明峰值仍在预算、超预算稳定降级,并为异步 view 跨帧设计确定性的失效检查。Sanitizer 不能保证抓到 release() 后悬垂:初始缓冲仍是一块存活的栈或成员存储,release() 通常不会像释放堆块那样让 ASan 给它加 poison。测试可让 FrameContext 携带递增 generation,借用句柄在解引用时核对 generation;也可在测试构建中于 release 后主动填毒初始缓冲,并配合上游堆块的 ASan 检查。generation 检查用于发现逻辑过期,填毒用于提高错误的可观察性,两者都不能替代正确的作用域所有权。

8. benchmark 要包含完整一帧

只循环 arena.allocate(24) 只能说明游标推进便宜。基准应使用同一算法和输入,比较普通容器每帧重建、普通容器长期 reserve 复用、固定缓冲 MBR 禁止回退、MBR 允许计数回退;若对象并非统一死亡,再加入 pool resource。

计时区内要包含临时容器构造、填充、消费、析构和 release,源数据准备与一次性初始化放在区外。用 benchmark 框架的 DoNotOptimize 或可核对 checksum 防止工作被删。发布优化下测性能,ASan/UBSan 单独测正确性。

报告每帧分布、较高分位和最坏样本对应输入规模,同时记录 upstream 次数、申请字节与峰值内存。MBR 不保证获胜:普通 vector 若已稳定保留容量,优势可能很小;MBR 中反复扩容又会让旧块占到帧尾,峰值可能更高。固定 CPU 策略、重复多轮并记录编译器与标准库版本,不虚构脱离环境的固定收益。

9. 上线前能被审查的契约

一套可靠实现应满足:

  • frame context 拥有缓冲与 MBR,resource 地址在使用期间稳定;
  • 所有 scratch 容器先析构,随后才 release,没有容器跨 release 复用;
  • 跨帧、跨线程和异步输出都转成持久所有权,view 不逃逸;
  • 嵌套字符串用长文本检查真实 resource,move/swap 不依赖错误传播假设;
  • 每线程独立资源,或 release 明确等待所有消费者完成;
  • upstream 禁止时有 bad_alloc 降级,允许时有次数与字节监控;
  • 容量由真实负载测量,基准同时报告时间分布与内存峰值。

MBR 的价值是让回收形状匹配数据生命周期:一帧临时数据一起失效,就一起回收。它没有修改 C++ 对象模型,也不会延长引用的生命。只要一个 view、字符串字符区或 vector capacity 越过 release(),快速释放就变成未定义行为。先用作用域证明生命周期,再用失败资源、分配计数和整帧基准验证收益,才是可维护的帧级内存池。

← 全部文章

johan's blog