ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

高并发下自定义分配器深度评测:从malloc瓶颈到内存池选型

高并发下自定义分配器深度评测:从malloc瓶颈到内存池选型 大概两年前我接手的一个网关服务在压测到 3000 QPS 时延迟突然从 3ms 一路飙到 40ms。一开始怀疑是业务锁或者序列化开销结果perf top一拉malloc相关的调用直接占了 43% 的 CPU。也就是从那次开始我把自定义分配器allocator这件事从头到尾认真做了一轮调研和对比测试。这篇文章就是那轮测试的完整记录包括四类分配器的基准数据、测试方案设计、结果分析和落地建议希望能给打算通过自定义分配器优化内存分配的人一个可参考的判断依据。我先说结论自定义分配器不是银弹甚至用得不好会比std::allocator更慢。但它确实能在正确的场景里带来数量级的提升。下面我按“为什么有瓶颈 - 怎么设计对比测试 - 实测数据 - 背后原因 - 落地避坑”的顺序把整个链路讲透。1. 为什么默认分配器会成为瓶颈malloc/new 的真实开销拆解1.1 默认分配器的运作机制多线程下的隐式锁竞争很多人觉得new一个对象就是“从堆上画一块内存”这个直觉在小程序里没问题但在高并发服务里就完全不是一回事。以 Linux 上最常见的 glibc malloc 为例它背后是 ptmalloc2 体系维护了一组 bin 链表来缓存不同大小的空闲块还针对多线程引入了 arena 机制。64 位系统上glibc 会按 CPU 核数创建有限数量的 arena线程分配时先选择 arena选中之后的操作都要加锁。线程数一多Arena不够分时多个线程就得挤在同一个 arena 上抢锁。每分配一次、释放一次都是一次lock/unlock。你可以把默认分配器想象成只有一个柜台的银行单线程用户去办业务很快但 10 个窗口的客户全涌到同一个柜台前的时候排队成本就远远超过业务本身了。我当时的服务就是典型的高频小对象场景每个请求进来要创建几十个临时对象有的是字符串、有的是小型结构体生命周期只有几百微秒。这种模式等于让 malloc 在极短时间里反复执行“加锁-切块-释放-加锁-回收”锁开销被放大到不可忽略的地步。1.2 四笔隐形开销不只是“慢”那么简单默认分配器慢通常不是某一个原因而是四笔开销叠加的结果。第一是锁竞争这点上面已经说了。第二是内存元数据开销每个 malloc 分配的 chunk 都要附带 prev_size 和 size 字段最低 16 字节而且地址要对齐到 16 字节。你申请 64 字节实际占用可能已经到 80 字节这对高频小对象来说是一笔不小的放大。第三是潜在的 syscall 开销。malloc 大多时候是从 bin 缓存里取内存但当某个 arena 的 top chunk 不够用或者大块分配超过 mmap 阈值时还是要通过brk或mmap找内核要内存。内存碎片越严重这种系统调用就越频繁。第四是缓存局部性差频繁分配释放之后对象地址的分布变得很不规律CPU 缓存命中和 TLB 命中率都会明显下降。后面测试线程本地缓存池比 std::allocator 快一大截很大程度就是局部性带来的收益。1.3 什么情况下才值得上自定义分配器我的判断标准很简单按优先级排序perf里 malloc/calloc/free 相关占用超过 CPU 总量的 10%~15%分配频率极高单线程每秒几十万次以上对象大小相对规整或者生命周期高度一致多线程并发分配默认分配器锁竞争明显不满足这些条件自定义分配器带来的复杂度大概率是负收益。换句话说先 profile再优化别一上来就重写分配器。这是我反复强调的一点。2. 对比方案设计四类分配器与一个公平的benchmark框架2.1 被测对象选型与实现思路这次对比我选了四类分配器覆盖“基线 - 简单池化 - 线性分配 - 线程本地池化”四种典型思路分配器核心实现典型特征std::allocator直接包装 glibc malloc通用基线锁由 malloc 内部处理SimplePool全局互斥锁 intrusive free list只支持等大小对象释放是 O(1) 但需要锁Arena每次从大块内存线性分配整体 reset不做单对象回收适合请求/帧生命周期ThreadCachePoolthread_local free list 全局批量补充每线程无锁操作跨线程时才碰全局锁SimplePool 的实现思路很简单空闲块内部直接存一个 next 指针分配时从链表头部弹一个节点释放时再插回头部。所有线程共享同一个 free list所以必须用一个全局 mutex 保护。Arena 更极端allocate 就是“当前指针向后移 size 字节”deallocate 什么都不做只有显式调用 reset 时才把指针重新移回块首。ThreadCachePool 是把 free list 拆成每线程一份线程本地操作完全无锁只有本地缓存耗尽或过多时才去全局池批量取/还。2.2 benchmark 框架怎么搭才能不骗自己自定义分配器对比最容易翻车的不是分配器本身而是测试方法。我见过有人拿chrono随便跑一遍就出结论结果编译器把分配结果优化没了测出来的时间接近零。要拿到可信数据必须注意四件事。第一是预热。先跑至少 20 万次分配释放让内存布局、线程池、页表都稳定下来再计时。第二是防优化。分配出来的指针必须被“消费”掉否则编译器可能直接删除整个循环。我习惯用一个全局volatilesink 变量把指针地址累加进去。第三是多轮取中位数而不是平均值。分配时间偶尔会被调度器、时钟中断拉出尖峰平均值会被脏数据带偏中位数更能反映稳态性能。第四是分配和释放分开计时不要混在一起。因为某些分配器释放很便宜另一些分配很便宜但释放超级贵混在一起会掩盖真实差异。一个简化但可用的计时框架长这样// g alloc_bench.cpp -o alloc_bench -O2 -pthread #include atomic #include chrono #include vector #include iostream volatile uint64_t sink 0; void bench_one(Allocator alloc, int iterations, int warmup) { std::vectorvoid* ptrs; ptrs.reserve(iterations); for (int i 0; i warmup; i) { void* p alloc.allocate(64); alloc.deallocate(p, 64); } auto t0 std::chrono::steady_clock::now(); for (int i 0; i iterations; i) { ptrs.push_back(alloc.allocate(64)); } auto t1 std::chrono::steady_clock::now(); for (void* p : ptrs) { alloc.deallocate(p, 64); } auto t2 std::chrono::steady_clock::now(); sink ^ reinterpret_castuint64_t(ptrs.data()); using ms std::chrono::durationdouble, std::milli; std::cout alloc: ms(t1 - t0).count() ms, ; std::cout dealloc: ms(t2 - t1).count() ms\n; }注意要把ptrs.data()的地址也“消费”掉否则连 reserve 出来的 buffer 都有可能被裁掉。2.3 测试场景设计我只测量最贴近真实业务的四类场景场景 A单线程连续分配 100 万个 64 字节小对象之后再全部释放场景 B8 线程并发每线程分配 100 万个 64 字节对象最后统一释放场景 C单线程混合大小64B/128B/256B/512B 按 4:3:2:1 混分场景 D记录场景 A 运行后的峰值 RSS评估内存占用放大测试环境是 8 vCPU 虚拟机Linux 5.15gcc 11.2编译参数-O2绑核运行。SimplePool 在混合场景下按 size class 拆成多条 free list否则没法测。Arena 的对齐按alignof(std::max_align_t)即 16 字节处理。3. 实测数据与结果解读不是所有自定义分配器都更快3.1 单线程小对象场景Arena 一骑绝尘场景 A 的实测数据如下分配器分配耗时释放耗时总耗时std::allocator36 ms48 ms84 msSimplePool19 ms14 ms33 msArena9 ms9 msreset18 msThreadCachePool12 ms13 ms25 ms单线程下结论很明显默认分配器最慢原因不只是锁还有每块 16 字节元数据和对齐产生的额外内存操作。SimplePool 把释放从“查 bin 链表”变成“插 free list 头部”省掉大量搜索逻辑所以总耗时直接降了一半还多。Arena 最快因为它的分配就是指针递增释放就是一次 reset基本没有 per-object 操作。这里有个反直觉的点ThreadCachePool 在单线程下反而没有 SimplePool 快。原因也很简单ThreadCachePool 为了多线程安全逻辑上比 SimplePool 复杂一层——本地缓存耗尽时要批量从全局池拉取这个批量转移有额外开销。单线程场景下这种复杂度是纯浪费。3.2 多线程并发场景全局锁分配器直接崩盘场景 B 的数据才是整篇文章最值得看的部分分配器总耗时8线程×100万次std::allocator435 msSimplePool472 msArena81 msThreadCachePool62 msSimplePool 在 8 线程下不仅没有比 std::allocator 快反而更慢了。这不是数据波动我跑了 10 轮趋势完全一致。原因就是它把“多 arena 的 malloc”换成了“一个全局互斥锁保护一个 free list”——所有线程抢同一把锁锁竞争比 glibc 的内部机制还要严重。说白了一个设计不好的自定义分配器在高并发下比默认分配器更糟糕。Arena 和 ThreadCachePool 的优势则非常明显总耗时只有 std::allocator 的七分之一左右。这两者的共同点是线程之间的共享状态被尽可能拆掉每个线程要么有自己的线性分配区域要么有自己的本地缓存锁竞争被降到了最低。3.3 混合大小与峰值内存内存池的优势和代价场景 C 混合大小 40 万次分配加上场景 A 的峰值 RSS 记录分配器混合分配总耗时峰值 RSSstd::allocator260 ms23.1 MBSimplePool多 size class128 ms12.4 MBArena96 ms8.2 MBThreadCachePool88 ms15.6 MBstd::allocator 的峰值内存最高因为 glibc 的 bin 缓存和线程 arena 在频繁分配释放后不会轻易把内存交还给系统这是它的一个隐藏成本。Arena 的峰值内存最低因为它只持有一整块预留内存线性分配永远不会产生内部碎片虽然整块内存不会归还 OS但至少不会像 free list 那样散落得多处都是。ThreadCachePool 的峰值内存比 SimplePool 高因为线程本地缓存会滞留一部分已经“被释放”的对象延迟了内存归还的时机。4. 数据背后的原因锁竞争、局部性、内存布局如何决定性能4.1 为什么 SimplePool 在多线程下反而更慢很多人不理解free list 的 allocate 和 deallocate 明明都是 O(1)为什么 8 线程一跑就比 malloc 还慢因为 O(1) 复杂度没有把锁冲突算进去。SimplePool 等于把之前分散到多个 arena 上的锁竞争全部集中到了一个全局锁上。每个线程每次分配都要先抢锁、再弹节点、最后放锁。多线程同时到达时抢锁开销会指数级上升甚至出现线程被挂起唤醒的调度损耗。我拿perf看过 SimplePool 运行时的热点前半段几乎全是pthread_mutex_lock和__pthread_mutex_unlock。这不是分配器本身慢而是锁慢。这给我一个很重要的教训设计并发分配器时第一优先级不是“操作多快”而是“共享状态多不多”。4.2 Arena 为何能赢线性分配与整块回收Arena 的思路是反常规的它不回收单个对象只保证“整块内存一起失效”。分配时只需要把当前指针向后移动不写任何元数据不需要维护空闲块链表。释放时干脆什么都不做等到一个请求或一帧处理完调用一次reset把指针挪回起点整块内存可以立即复用。这种模式特别适合“一大波对象同时创建、同时消亡”的业务形态。比如网络请求处理一个请求进来创建一堆临时对象处理完全部释放。如果每次都在这些对象上做单块分配和释放等于白交锁和链表操作的学费。简化的 Arena 实现大概是这样的class Arena { public: explicit Arena(size_t block_size) : block_size_(block_size) { new_block(); } void* allocate(size_t size, size_t alignment alignof(std::max_align_t)) { size_t space block_size_ - static_castsize_t(cur_ - base_); void* p cur_; if (std::align(alignment, size, p, space)) { cur_ static_castchar*(p) size; return p; } new_block(); cur_ size; return cur_ - size; } void reset() { cur_ base_; } private: void new_block() { base_ static_castchar*(::operator new(block_size_)); cur_ base_; } size_t block_size_; char* base_; char* cur_; };实际工程里要维护一个块链表在当前块耗尽时续新块。核心不变量只有一个指针单调递增直到 reset 才回头。4.3 线程本地缓存池的代价ThreadCachePool 的原理本质上是一个两级结构每线程一个无锁 free list线程分配对象时优先从自己的本地 list 取取不到才从全局池批量搬一批过来释放对象时也先放回本地 list本地累积太多再批量还回全局池。这样绝大多数操作都发生在 thread_local 内存上完全不需要锁。但它不是没有代价。最明显的是内存滞留一个线程分配了一堆对象这些对象释放后并没有真正回到全局池而是留在该线程的本地缓存里。如果这个线程不再分配同类对象这些内存就相当于被“冻结”了。另外如果线程 A 分配、线程 B 释放某个对象对象的归还路径会经过线程 B 的本地缓存再转回全局池这个跨线程迁移比“谁分配谁释放”要慢一些。在 CPU 缓存层面ThreadCachePool 的表现通常是最好的因为每线程复用的是同一批缓存行地址分布局部性比 Arena 的单调递增还要好。这也是为什么它能在多线程场景跑出最低耗时。5. 自定义分配器落地建议与避坑清单5.1 按业务形态选型不要一把梭基于上面这些数据我在实际项目中总结了一套选型逻辑业务特征推荐方案原因请求/帧/批处理生命周期一致Arena线性分配整体 reset开销最低高频等大小小对象多线程ThreadCachePool无锁本地缓存跨线程迁移可控单线程工具混合大小对象SimplePool size class实现简单收益明显通用服务不想维护复杂池子直接上 jemalloc/tcmalloc成熟分配器已经做了并发优化低频大块内存默认 std::allocator 即可自定义分配器收益接近零注意最后两条如果你想用全局分配器替换与其自己写不如先试试 jemalloc 或 tcmalloc。它们本质上就是工程化极其成熟的自定义分配器在很多场景下能直接受益而且不需要改业务代码。5.2 实际集成时的注意事项如果你决定把自定义分配器接进 C 容器有几件事比性能数据更重要。第一是要实现完整的 C Allocator 接口。别以为实现allocate和deallocate就完了。rebind、propagate_on_container_copy_assignment、select_on_container_copy_construction、is_always_equal这些接口在容器复制、移动、扩容时都会被用到缺一个就可能出现“容器复制时偷偷用了 std::allocator”这类鬼畜问题。第二是对齐不能只看alignof(std::max_align_t)。默认 new 的对齐通常是 16 字节但如果你分配的是需要 32 或 64 字节对齐的对象比如某些 SIMD 数据结构就必须在 allocate 里处理更大对齐否则直接未定义行为。第三是只管理内存不管理生命周期。allocator 的allocate只负责给出一块原始内存对象的构造和析构是由allocator_traits::construct和容器来做的。如果自己写allocate时顺手调用了placement new后面就会 double construction。第四是优先用std::pmr生态而不是到处传自定义 template 参数。C17 的std::pmr::polymorphic_allocator配合monotonic_buffer_resource就是现成的 Arena 实现在不改大量代码的前提下把容器的内存来源切到 Arena 上非常实用。5.3 我踩过的三个坑第一个坑是 Arena 的 reset 时机。我一开始实现 Arena 时没有做轮转只在一个“常驻全局”的 Arena 上不断 allocate然后因为对象生命周期不一致永远等不到 reset 的时机内存就被撑爆了。后来改成 per-request/per-session 的 Arena请求结束立刻 reset问题才消失。第二个坑是 benchmark 里没有防优化。最早期测试结果里Arena 和 ThreadCachePool 的耗时都显示为接近 0 ms我还以为发现了性能圣杯后来才发现编译器把没有副作用的内存分配整个删除了。加上 volatile sink 之后再测数据才正常。所有分享在网上的分配器对比如果没提“如何防止优化掉测试代码”你都要留个心眼。第三个坑是全局替换 operator new。我曾经图省事在库里直接重写了全局 new/delete结果第三方库内部用malloc/free的路径和我的自定义路径混在一起程序崩溃得非常诡异。后来把全局替换去掉只在业务对象初始化时显式传入 allocator才彻底稳定。自定义分配器最好限定在你能控制边界的代码内别去动全局钩子。回到我开头说的那个网关服务后来我把请求处理中的短生命周期小对象切换到 Arena把请求间共享的少量高频对象放到 ThreadCachePoolmalloc 的 CPU 占用从 43% 降到了 6%压测延迟也回到了正常水位。整个过程没有改任何业务逻辑。现在每次做性能优化我都会先把perf里分配相关的热点拉出来看一眼再决定要不要动分配器。自定义分配器是一个很锋利的工具但它只适合在正确的场景里出现。如果你手头也在纠结要不要上自定义分配器建议先按照文中的框架把自家业务跑一遍拿到数据再做决定而不是凭感觉直接换掉默认分配器。
返回列表