恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
自定义分配器与内存池:std::pmr 从零到 free list
首页
资讯中心
/
自定义分配器与内存池:std::pmr 从零到 free list
自定义分配器与内存池:std::pmr 从零到 free list
发布时间:2026/10/17 6:39:35
std::vector在背后替你管理内存代价是每次扩容都会调operator new向系统要一块更大的内存、再把旧元素搬过去。当一次循环里 push 上万个元素、或频繁创建销毁小对象时这些系统调用会变成可感知的开销。C17 引入的std::pmrpolymorphic memory resource把「内存从哪来」从写死的new/delete里抽出来变成运行期可切换的策略——这篇会实测它省下多少次分配并带你手写一个 free list 内存池把原理看穿。1. 引子std::allocator 每次扩容都在付系统调用的钱先看一个事实std::vectorint用默认分配器std::allocatorint它的allocate最终落到全局operator new。vector 的容量增长策略是翻倍gcc/libstdc 下所以 push 1000 个元素会触发约 11 次向系统要内存。而绝大多数要到的内存最后只是用来搬移旧元素然后旧内存又被delete掉。官方文档std::allocator——默认分配器allocate()的典型实现就是包一层::operator new。这里的关键词是「系统调用」。operator new背后是mallocmalloc要维护空闲链表、可能触发brk/mmap系统调用、要考虑线程安全。单次看起来不贵但「频繁的小对象分配」会把它放大。所以性能工程里有个共识把多次小分配合并成一次大分配再自己切块。这就是内存池的出发点。2. 核心概念memory_resource 与 polymorphic_allocatorstd::pmr由两个角色组成角色类职责内存资源std::pmr::memory_resource抽象基类定义do_allocate/do_deallocate/do_is_equal回答「内存从哪来」多态分配器std::pmr::polymorphic_allocatorT包装一个memory_resource*满足Allocator要求可直接喂给容器便捷别名std::pmr::vectorTstd::vectorT, std::pmr::polymorphic_allocatorT的别名继承体系长这样内存资源继承体系std::pmr ═══════════════════════════════════════════════════════════════ std::pmr::memory_resource 抽象基类 │ do_allocate / do_deallocate / do_is_equal ┌───────────┼───────────────────┬──────────────────────┐ │ │ │ │ new_delete monotonic_buffer unsynchronized_pool synchronized_pool _resource _resource _resource _resource 默认包 一次分配、 无锁空闲块池 带锁版本 operator new 批量释放 适合同尺寸小对象 线程安全 ═══════════════════════════════════════════════════════════════ polymorphic_allocatorT 持有一个 memory_resource* 指针 容器只认这个 allocator具体内存策略由 resource 决定 —— 「可插拔」。官方文档std::pmr::memory_resource、std::pmr::polymorphic_allocator「多态」二字的含义普通std::allocatorT的类型里就焊死了分配策略换策略得换类型而polymorphic_allocator只是持有一个基类指针运行期传入不同的 resource 就切换了策略同一个容器类型可以对应任意内存来源。3. monotonic_buffer_resource一次分配、批量释放monotonic_buffer_resource是最简单也最常用的 resource。它向一块上游 buffer 单调地「切蛋糕」只前进不后退#includeiostream#includememory_resource#includecstddefintmain(){charbuf[256];std::pmr::monotonic_buffer_resource pool{buf,sizeof(buf)};void*apool.allocate(16,8);void*bpool.allocate(16,8);std::cout连续分配 b - a static_castchar*(b)-static_castchar*(a) 字节\n;pool.deallocate(a,16,8);// 空操作monotonic 不归还单块内存pool.deallocate(b,16,8);pool.release();// 一次性把指针拨回起点全部归还void*cpool.allocate(16,8);std::coutrelease 后重新分配 c a? (ca?是:否)\n;}连续分配 b - a 16 字节 release 后重新分配 c a? 是三个要点deallocate是空操作。monotonic 只记录一个「当前位置」单块释放不会把空间还回来——它本来设计给「一批对象一起用、用完整批释放」的场景比如一次请求里创建的所有临时对象。release()才是真正的释放。它把当前位置拨回 buffer 起点之后分配的内存地址重新从a开始——所以c a。buffer 不够时自动回退上游。如果你给它的buf太小它会继续向上游默认new_delete_resource要新的块而不是报错。所以它永远不会「分配失败」。官方文档std::pmr::monotonic_buffer_resource——注意release()之后原来指针就悬垂了不能再访问。4. 分配次数实测11 次 vs 0 次用重载全局operator new挂一个计数器直接数「向系统要了多少次内存」这是确定性指标不依赖计时#includeiostream#includevector#includememory_resource#includecstdlib#includenewstaticstd::size_t g_allocs0;// 重载全局 operator new 来数分配次数本主题需要观察分配行为属正当用法void*operatornew(std::size_t n){g_allocs;if(void*pstd::malloc(n))returnp;throwstd::bad_alloc();}voidoperatordelete(void*p)noexcept{std::free(p);}voidoperatordelete(void*p,std::size_t)noexcept{std::free(p);}intmain(){{std::vectorintwarm{1};}// 预热让 cout/locale 的分配先发生完{std::size_t beforeg_allocs;std::vectorintv;for(inti0;i1000;i)v.push_back(i);std::coutstd::allocator: (g_allocs-before) 次分配\n;}{charbuf[64*1024];std::pmr::monotonic_buffer_resource pool{buf,sizeof(buf)};std::size_t beforeg_allocs;std::pmr::vectorintv{pool};for(inti0;i1000;i)v.push_back(i);std::coutpmr monotonic: (g_allocs-before) 次分配\n;}}std::allocator: 11 次分配 pmr monotonic: 0 次分配差距默认分配器因为翻倍扩容向系统要了 11 次内存还伴随搬移pmr::vector配合 64KB 的栈上 buffer0 次系统分配——它直接在buf里切块。三种资源放一起对比资源分配来源deallocate 是否真归还适合场景分配次数1000 次 pushnew_delete_resource默认系统堆operator new是通用、生命周期不规律11monotonic_buffer_resource一次性大块 内部切否release()才归还一批对象用完整体释放0unsynchronized_pool_resource从上游切出同尺寸块池是回池单线程大量同尺寸小对象少首次要、之后复用官方文档std::pmr::unsynchronized_pool_resource——它是标准库自带的 free list 池适合「分配/释放都很频繁」的场景。5. 手写 free list 内存池节点复用monotonic 的问题是不能回收单个块。当对象生命周期交错有的早就该释放、有的还活着需要能复用已释放块的结构free list空闲链表就是答案。最小实现#includeiostream#includecstddef#includenew// 侵入式 free list 内存池释放的块用「块内前 8 字节」串成链表。// 这里直接调 ::operator new/delete是因为内存池本身就要接管系统内存主题所需。classFreeListPool{public:structNode{Node*next;};Node*head_nullptr;void*allocate(std::size_t n){if(nsizeof(Node))returnnullptr;// 教学版只支持 ≤ 8 字节的小对象if(head_nullptr){return::operatornew(sizeof(Node));// 池空向系统要一块}Node*phead_;// 命中空闲块摘下头节点head_head_-next;returnp;}voiddeallocate(void*p,std::size_t){Node*nodestatic_castNode*(p);// 归还插入链表头LIFOnode-nexthead_;head_node;}};intmain(){FreeListPool pool;void*apool.allocate(8);void*bpool.allocate(8);pool.deallocate(a,8);// 归还 aa 入链pool.deallocate(b,8);// 归还 bb 成为新链头void*cpool.allocate(8);// 命中 free list拿到 bvoid*dpool.allocate(8);// 再拿拿到 astd::cout复用后 c b? (cb?是:否)\n;std::cout复用后 d a? (da?是:否)\n;::operatordelete(c);// 演示结束归还两块真实代码里交给容器/智能指针管理::operatordelete(d);}复用后 c b? 是 复用后 d a? 是输出证明了两件事deallocate后块没有丢而是回到了链表中后续allocate不再向系统要内存直接复用旧块LIFO 顺序所以cb、da。free list 的内存结构长这样侵入式 free list每个空闲块的前 8 字节存 next 指针 ═══════════════════════════════════════════════════════════════ head_ ──┐ ▼ ┌─────────────┐ ┌─────────────┐ │ next ────────┼────▶│ next ────────┼────▶ nullptr │ (空闲块 b) │ │ (空闲块 a) │ └─────────────┘ └─────────────┘ 归还deallocate把块插到 head_ 前面 取出allocate 把 head_ 摘下来返回 —— 所谓「侵入式」是 next 指针就存在被释放块自己体内 不额外 malloc 一个链表节点零额外开销。 ═══════════════════════════════════════════════════════════════这个「侵入式」intrusive是 free list 的精髓链表节点就是被管理的块本身管理结构不产生任何额外分配。生产级的池子如unsynchronized_pool_resource会把它做成「按尺寸分桶」的多个链表避免不同大小对象互相污染。6. 完整示例内存策略可插拔std::pmr最大的工程价值是让「函数/容器」和「内存策略」解耦。同一个函数调用方传入不同 resource就切换了内存来源而函数代码一行不改#includeiostream#includememory_resource#includevector// 与内存策略解耦的函数只依赖 std::pmr 接口不关心内存到底从哪来std::pmr::vectorintmake_sequence(std::pmr::memory_resource*r,intn){std::pmr::vectorintv{r};for(inti0;in;i)v.push_back(i);returnv;}intmain(){// 方案 A栈上 4KB buffer零堆分配charstack_buf[4096];std::pmr::monotonic_buffer_resource stack_res{stack_buf,sizeof(stack_buf)};autoamake_sequence(stack_res,10);// 方案 B默认堆资源包 operator newautobmake_sequence(std::pmr::get_default_resource(),10);std::cout两种资源产生的结果一致? (ab?是:否)\n;std::cout同一份代码内存来源由调用方决定\n;}两种资源产生的结果一致? 是 同一份代码内存来源由调用方决定什么时候值得上自定义分配器三条判断标准判断结论对象小而多、生命周期整齐一次请求一批用monotonic_buffer_resource零系统分配对象生命周期交错、频繁创建销毁同尺寸对象用unsynchronized_pool_resource或自写 free list只有零星几次分配、对象又大别折腾默认new/delete就够复杂度不划算官方文档C Core Guidelines R.11避免显式 new/delete——内存池的价值是把「裸 new/delete」收进一个受控的地方业务代码依然走 RAII 和容器。7. 延伸阅读std::pmr::memory_resourceresource 基类的三个虚函数签名写自定义 resource 时照这个来std::pmr::monotonic_buffer_resourcerelease()的语义和上游回退行为std::pmr::polymorphic_allocator多态分配器如何满足 Allocator 要求std::pmr::unsynchronized_pool_resource标准库自带的 free list 池想省事直接用operator new全局重载这篇计数手法依赖的全局替换点本知识库内的相关篇目《C 缓存预热与 prefetch什么时候该手写》 —— C 缓存预热与 prefetch同体系里最该一起看的另一篇。《基准测试的陷阱优化掉、频率漂移、缓存预热》 —— 基准测试benchmark的三个经典陷阱《SIMD 与自动向量化marchnative、__restrict 与 likely》 —— 自动向量化auto-vectorization让编译器把标量循环翻译成 SIMD 指令8. 一句话总结std::pmr把「内存从哪来」变成运行期可插拔的策略polymorphic_allocator持有memory_resource*monotonic_buffer_resource一次分配批量释放实测把 1000 次 push 的系统分配从 11 次降到 0 次生命周期交错的小对象则交给 free list 内存池复用节点判断标准是「分配是否频繁 生命周期是否整齐」不满足就别为池子增加复杂度。