这系列走到第三篇,内存池的主干其实已经定型了。前面两篇把三层结构(thread cache、central cache、page cache)和基本分配流程搭完,能跑通 Demo,但真正放到多线程压力下测,很快就会发现:“能跑”和“能扛并发”完全是两码事。第三篇我不打算继续堆新功能,重点放在并发、锁、内存回收和验收这几件事上。
第一版压测结果说实话有点难看。8 个线程同时不停分配释放,吞吐量只比单线程高了一点点,反而比系统默认分配器还慢。原因后面会展开:我给 central cache 加了一把全局锁,把多线程访问直接序列化了。所以这版做得最多的是:把 central cache 拆锁、把 page cache 合并逻辑补上、再设计一套能客观衡量并发性能的压测程序。
如果你也在写类似的 C++ 内存池,或者对多线程内存分配的性能瓶颈感兴趣,这篇应该能帮你少走几步弯路。文中所有代号都是模拟项目 X 的内部实现,验证环境用某测试机,跑的是自写基准,结论只对这套代码负责,不代表任何真实产品或环境。
1. 从框架到并发:第三版升级的真实动机
1.1 前两版交付的三层结构,谁在干什么
内存池的核心套路其实不复杂:一次性从系统申请大块内存,切成小块,按不同规格管理,线程用的时候直接拿现成的,不够再去系统申请。模拟项目 X 采用的就是经典的三层结构:
- thread cache:每个线程一份,完全无锁,负责高频小对象的快速分配和释放;
- central cache:所有线程共享,负责给 thread cache 批量供给内存块,同时回收多余的块;
- page cache:按“页”管理大块内存,负责把内存切成各种规格的 span 分给 central cache,也负责把回收的内存合并成大块。
前两版做完之后,单线程遍历、连续分配、连续释放的测试都能过,功能上没有问题。但一旦把线程数调到 4 个以上,性能曲线就开始不对劲。一开始我以为是系统负载问题,后来用统计计数查了一下,发现 central cache 那把锁的等待次数高得离谱。
1.2 这三个矛盾才是这版要动刀的地方
第一,锁竞争。所有线程从 central cache 拿内存时都要抢同一把锁,线程越多,排队越严重。某测试机上 16 线程压测时,锁等待耗时占到了总分配耗时的 60% 以上,这基本等于把高并发跑成了串行。
第二,只有分配路径没有回收路径。第二版里 thread cache 里多余的内存只是“逻辑上”释放了,实际并没有还给 central cache;大 span 用完后 page cache 也从来不合并,跑一段时间后碎片化严重,某些 64KB 以上的大块申请只能频繁调用系统分配接口,性能急剧下降。
第三,没有数据就没有改进方向。前两版我靠“感觉”调优,压测也就是简单跑一下看有没有崩溃,既不知道锁等待多严重,也不知道碎片率多高。第三版开始前,我给分配器内部加了可选的统计开关,用条件编译控制,默认关掉、压测时打开。
这三个矛盾不是独立存在的,锁竞争会放大回收不彻底的副作用,回收不彻底又会增加 central cache 的压力,进而加剧锁竞争。所以这版的改动顺序是:先拆锁,再补回收,最后用压测验证整体效果。
2. 中央缓存的多核改造:从一把大锁到按桶加锁
2.1 全局锁为什么会让多线程配置退化
前两版 central cache 的结构非常简单粗暴:一个大 free list 数组,按对象大小分成若干桶,每桶挂内存块,外部一个全局 mutex 保护所有桶。逻辑上没有任何问题,但并发下性能很差。
原因有两个。一是排队。假设 16 个线程同时触发 central cache 批量获取,每个线程都要拿 8 个对象,mutex 只能让它们一个个过;第 16 个线程可能已经自旋等待了很久。二是缓存行震荡。多个线程抢同一个锁时,锁变量所在的缓存行不断在不同核心间同步,这比锁本身还贵。实测 8 线程时,跨度在 4 字节大小区间的随机分配场景下,第一版内存池居然比系统默认分配器慢了 30%。
这个问题的本质是“共享可变量太多”。thread cache 已经把高频路径解耦了,但低频路径上的耦合依然能把并发收益全部吃掉。
2.2 桶锁设计:按 size class 分桶,而不是按哈希散列
第二版采用的方案是把一个大锁拆成多个锁。最直觉的做法是弄一个锁池,内存块地址进来后用哈希散列到某个锁上。我试过,效果一般,因为内存块地址分布并不均匀,而且同一个 span 里的内存块地址相邻,散列很容易命中同一个桶。
更合理的做法是直接按 size class 分桶:每个桶只对应某几种固定大小,桶内自己加锁。这样两个线程只要申请的不是同一规格内存,就完全不会互相影响。即使申请同一规格,它们要抢的 also 只是那一对 free list,其他规格的线程畅通无阻。
我现在的数据结构大致长这样:
struct CentralCache { // 每个桶维护一组跨度,桶内加锁 std::vector<SpanList> bucket_; // 按 size class 下标 std::vector<std::mutex> lock_; // 与 bucket_ 一一对应 }; void* CentralCache::FetchFromBucket(size_t sizeClass) { std::lock_guard<std::mutex> guard(lock_[sizeClass]); // 找到非空 span,从中摘一个 batch 对象返回 } void CentralCache::ReturnToBucket(size_t sizeClass, void* start, size_t n) { std::lock_guard<std::mutex> guard(lock_[sizeClass]); // 把 n 个对象挂回对应 span 的 free list }桶的数量怎么定?我直接用了 size class 的数量,每个 class 一个锁,不额外散列。实际压测下来,32 个以内的桶锁在并发上表现很好。如果担心某些超热门 size class 的锁竞争,可以进一步做“细粒度锁”,比如同一个 size class 下面按 span 分段加锁,但收益很有限,而且容易引入死锁复杂度,不推荐一开始就这么干。
2.3 快路径无锁设计:为什么 thread cache 可以不碰锁
桶锁解决了 central cache 层面的竞争,但真正让性能起飞的是 thread cache 的无锁快路径。
thread cache 的实现用的是线程局部存储。每个线程启动时,分配器会先给它建立一份私有的 free list 数组,不与其他线程共享。线程从自己的 free list 里拿内存块时,没有任何锁操作,因为这块内存区域天然只有本线程能访问。
class ThreadCache { public: void* Allocate(size_t sizeClass); void Deallocate(void* ptr, size_t sizeClass); private: std::array<FreeList, kClassNum> freeList_; }; // TLS 单例 static thread_local ThreadCache* tlsCache = nullptr; void* ThreadCache::Allocate(size_t sizeClass) { void* ptr = freeList_[sizeClass].Pop(); if (!ptr) { // 从 central cache 批量拿一批 ptr = FetchFromCentral(sizeClass, kBatchSize); } return ptr; }这里有一个看起来很平淡但极重要的参数:kBatchSize。它决定线程每次从 central cache 拿多少对象。拿太多,其他线程在 central cache 排队时间更长,容易出现“一个线程囤货、其他线程等货”的问题;拿太少,线程很快用完,频繁触发 central cache 操作,锁竞争又上来了。
我压测过几种取值,4、8、16、32。综合来看 8 是个比较稳的点:16 线程随机分配场景下吞吐最高,且单线程场景下额外开销可以忽略。
3. span 生命周期与页合并:回收路径上的深坑
3.1 页号映射表:一切回收的起点
前面一直在讲分配,但内存池做得好不好,一半看回收。corner case 几乎全在回收路径上。
回收的第一步是“对象到 span”的定位。用户释放一个指针,分配器怎么知道这个指针属于哪块内存、哪个 size class?这要靠页号映射表。
每个 span 本身就是若干连续页的抽象描述。分配器可以从任意地址算出它所在的页号,然后在一张全局哈希表里查页号对应的 span。页号计算很简单:地址右移 12 位(假设 4KB 页)。
struct Span { PageID pageId_; size_t pageCount_; size_t sizeClass_; // 0 表示已被切分为小块,非 0 表示直接管理 Span* next_; Span* prev_; FreeList freeList_; bool isUsed_; }; Span* PageCache::GetSpanByPage(PageID pageId) { std::lock_guard<std::mutex> guard(mapLock_); auto it = spanMap_.find(pageId); return it == spanMap_.end() ? nullptr : it->second; }设计中容易忽略的一点是:同一个 span 切出来的多个对象释放时,可能来自不同线程,所以往 span 的 free list 挂对象时必须加锁。这里我复用了 central cache 的桶锁,不额外开锁,因为 span 已经按 size class 挂在 central cache 的桶里,这样保证同一个 span 上的操作串行化。
3.2 内存碎片和页合并策略
对象释放回 span 之后,这个 span 的对象会慢慢增多。当整个 span 的所有对象都被归还,也就是 free list 长度恢复到满员状态时,这个 span 就“空闲”了。此时需要把它从 central cache 的桶里摘掉,整块还给 page cache。
如果只是把 span 还给 page cache 而不做合并,长期运行后内存会被切得七零八落。比如一个 4 页的 span 被拆成两个 2 页的 span,又各自释放了一部分,page cache 里全是 2 页的小块。这时候来了一个 3 页的申请,无法直接满足,只能走系统分配,性能就会波动。
正确的做法是:page cache 收到空闲 span 后,检查它前后的页是否也是空闲的。如果相邻页空闲且属于同一个管理单元,就把它们合并成一个更大的 span,再放入 page cache 的空闲链表中。
void PageCache::MergeSpan(Span* span) { Span* prev = FindPrevSpan(span); Span* next = FindNextSpan(span); if (prev && !prev->isUsed_ && prev->pageId_ + prev->pageCount_ == span->pageId_) { // 合并 prev 和 span } if (next && !next->isUsed_ && span->pageId_ + span->pageCount_ == next->pageId_) { // 合并 span 和 next } // 更新 pageId_、pageCount_、映射关系 }合并的关键是 span 里必须存 pageId_ 和 pageCount_,而且哈希表里每个页都要指向同一个 span。最初我只存了首页映射,某次释放后半段地址时,哈希查不到 span,直接崩溃。后来改成 span 覆盖的所有页都在 map 里登记,内存多花一点,但正确性提高很多。
3.3 thread cache 的缩容与归还时机
线程本地缓存最大的风险是“只进不出”。一个线程频繁分配又释放,thread cache 里的 free list 可能积累到几十 MB,这些内存其他线程用不了,白白占用。
我给 thread cache 加了一个容量上限,默认 256KB。每次释放对象时检查总字节数,超过上限就把多余的对象归还给 central cache。归还的对象不是任意挑的,而是优先归还“当前 free list 最长”的那个 size class,这样既能快速降低总量,又不会影响本线程短期内的再次分配。
线程退出时也必须处理:把 thread cache 中所有剩余对象一次性归还到 central cache,否则这个线程的私有内存区域销毁后,内存就永久泄露了。这个逻辑在 TLS 析构函数里做,不能省。
提示:thread cache 缩容归还时,不要直接还给操作系统。归还到 central cache 就够了,系统层面的还给发生在 page cache 缓存超阈值时。否则线程下次分配又要重新走系统申请,缓存的意义就没了。
3.4 RSS 为什么会一直涨
社区里经常有人问:内存池跑久了,为什么 RSS(物理内存占用)一直在涨,是不是内存泄漏?
大部分情况不是泄漏,而是“内存被分配器缓存住了”。分配器为了快速响应后续请求,会尽量兜住不用的内存。span 释放后先进 page cache 的空闲链表,不会立刻还给系统;page cache 会保留一定量的缓存页,超过某个阈值才考虑归还系统。
这一点在压测数据上同样能看到:跑完 benchmark 后内存池的 RSS 会比系统默认分配器高一些。要判断是不是泄漏,要看内存是否持续增长且无上限。我的做法是:压测时分别记录 RSS 和“实际使用内存字节数”两个指标,如果前者稳定在某个水平,后者接近 0,说明内存只是被缓存了,不算泄漏。
4. 压测方案与性能数据:怎样才算“优化成功”
4.1 压测程序的设计
内存池压测最忌讳只测“固定大小、顺序分配、顺序释放”。这种模式既不能体现锁竞争,也不能暴露碎片问题。
我给模拟项目 X 设计了一套基准,核心思想是模拟真实负载的混乱状态:
- 对象大小混合:8 字节、32 字节、128 字节、512 字节、4KB 随机混合,模拟不同业务场景;
- 分配释放顺序打乱:只分配一部分先不释放,再分配另一部分,随机释放前面的部分;
- 线程数梯度:1、2、4、8、16 线程分别跑;
- 统计指标:每秒操作数(ops/sec)、平均延迟、RSS 上限、锁等待次数。
void BenchmarkThread(int threadId, int objCount, std::atomic<long long>* totals) { std::vector<void*> ptrs; ptrs.reserve(1024); for (int i = 0; i < objCount; ++i) { size_t sz = kSizePool[rand() % kSizePool.size()]; void* p = pool.Allocate(sz); ptrs.push_back(p); if (ptrs.size() > 512) { int idx = rand() % ptrs.size(); pool.Deallocate(ptrs[idx], sizes[idx]); ptrs.erase(ptrs.begin() + idx); } totals->fetch_add(1, std::memory_order_relaxed); } }这套基准比较粗糙,但足够暴露问题。真实跑的时候还要注意:每个线程的分配释放比例要可控,最好固定分配与释放的比例为 3:1,否则负载类型不同,数据没法横向比较。
4.2 某测试机上的实测数据
测试环境是一台 12 核的某测试机,运行一个 Ubuntu 风格系统,编译器开 O2。对比对象是系统默认分配器与本内存池。
表格里的数据是自己写的基准程序实测结果,不代表任何通用结论,仅供参考:
| 线程数 | 系统分配器(M ops/s) | 本内存池第三版(M ops/s) | 提升比例 |
|---|---|---|---|
| 1 | 4.2 | 4.5 | 约 7% |
| 2 | 6.8 | 8.3 | 约 22% |
| 4 | 10.1 | 15.2 | 约 50% |
| 8 | 13.5 | 26.8 | 约 98% |
| 16 | 16.2 | 38.4 | 约 137% |
单线程提升很小,这符合预期,因为单线程下 thread cache 的命中率和系统分配器 tcache 差不多。多线程场景差距拉开的根本原因是:系统分配器虽然也有线程缓存,但遇到频繁的跨线程释放和随机大小分配时,中央管理结构的锁竞争比我们细粒度桶锁更明显。
也要说明一个我不意外的现象:单线程下内存池偶尔还比系统分配器慢几个百分点。原因是内存池每次分配都要查 size class 映射,多一层计算开销。如果你的应用单线程、分配模式规律,系统分配器确实够用。
4.3 通过统计计数确认锁等待的下降
为了让优化结果有说服力,我在 central cache 的每个桶锁前后加了原子计数器,统计等待次数。
改造前:16 线程、10 秒测试时间内,全局锁等待次数大约 230 万次,平均等待 92 微秒。改造后:同一个基准,桶锁总等待次数降到约 60 万次,平均等待 24 微秒。总等待时间下降了约 70%。
统计开关用条件编译控制,正常发布版本置零:
#ifdef MEMORY_POOL_STATS_ON std::atomic<uint64_t>* g_lockWaitCount[32]; std::atomic<uint64_t>* g_lockWaitTime[32]; #endif这个数据比 ops/sec 更有说服力,因为它直接关联到“多核扩展性”这个内存池的核心价值。锁等待降下来,吞吐自然上去。
5. 测试中暴露的坑与最终参数选择
5.1 锁顺序死锁,差点让我以为 CPU 坏了
第三版引入桶锁后,测试中出现过一个隐性死锁:现象是程序卡死,但 CPU 占用率几乎为零。
排查后发现是锁获取顺序问题。一条路径是:thread cache 不够了,去 central cache 拿内存,先拿 central 的桶锁;桶里没有 span,再向 page cache 要新 span,拿 page cache 的锁。另一条路径是:thread cache 回收时,某些情况下会触发 page cache 的合并,先拿 page cache 的锁;合并过程中发现 span 对应的 central cache 桶状态需要更新,又回头拿 central 的桶锁。
两条路径方向相反,刚好构成死锁:一个线程持有 central 锁等 page 锁,另一个持有 page 锁等 central 锁。
解决办法是统一全局锁获取顺序:一律先拿 central 锁,再拿 page 锁。需要在代码注释里明确标注,review 时重点关注。这种死锁只在低概率时序中出现,压测跑几十轮才出现一次,很难复现,但一旦出现在生产环境就是事故。
5.2 对齐粒度的浪费问题
内存池按 size class 管理内存,对象大小必须对齐。如果对所有对象统一按 8 字节对齐,申请 1 字节的对象会浪费 7 字节;但申请 1KB 的对象只浪费 7 字节,比例很小。
问题出现在 1~8 字节高频申请的场景。压测里我专门跑了一个只申请 8 字节以下小对象的用例,发现内存池的 RSS 比系统分配器高了近 3 倍,就是因为对齐浪费。
最终采用分级对齐策略:0~128 字节区间按 8 字节对齐;128~1024 字节区间按 16 字节对齐;1024 字节以上按页对齐。这样既控制了碎片率,又避免了极端浪费。
5.3 一组适合大多数场景的初始参数
参数没有绝对最优解,但可以给一组经过多轮压测验证比较稳的初始值,适合模拟项目 X 这种分配模式中等随机的场景:
| 参数 | 取值 | 作用 |
|---|---|---|
| thread cache 最大容量 | 256KB | 控制线程本地积压内存 |
| central 批量获取大小 | 8 个对象 | 平衡锁竞争与线程饥饿 |
| 桶锁数量 | 与 size class 数量一致 | 细粒度并发控制 |
| page cache 最大缓存 | 128MB | 超过后归还系统 |
| 对齐策略 | 分级对齐(8B/16B/页) | 降低碎片率 |
| span 合并阈值 | 无条件合并 | 避免大块申请走系统分配 |
打算长期用的读者,我建议基于这个初始值跑两轮梯度压测。第一轮固定线程数,扫描批量大小;第二轮固定批量大小,扫描 thread cache 上限。预算有限的话两轮就够了,不用全参数网格搜索,太费时间。
注意:这些参数直接决定内存池的缓存行为。如果应用分配模式极端——比如纯大块分配、或者纯频繁小对象跨线程释放——一定要针对性调整,不要直接照搬。
5.4 还有两个容易被忽略的实现细节
第一,span 的 free list 用“后进先出”还是“先进先出”?我用的是 LIFO。因为刚释放的内存块大概率还在 CPU 缓存里,后进先出让下一次分配直接命中缓存,能省一次缓存行填充。压测下来 LIFO 比 FIFO 在分配密集场景下快约 5%。
第二,thread cache 的 free list 可以存“对象数量”,也可以只存“链表头”。我在每个桶里额外维护了字节数累计,用于快速判断是否超过上限。如果只存链表头,缩容时需要遍历链表统计长度,那个开销在低频回收路径上不明显,但如果线程频繁跨上限,就会增加延迟。用空间换时间在这里是划算的。
这版本迭代下来我最大的体会是:内存池做到后期,真正难的不是“内存管理”,而是“并发调度”。前两个版本我一直在抠分配释放逻辑的细节,真正让性能起变化的,是锁拆分和回收时机两个点。如果你也在做类似的东西,建议把重心尽早挪到这两个地方。
最后分享一个小技巧:在分配器内部埋一套默认关闭的统计计数,等 bug 出现或性能退化时打开,跑一轮就导出到文件。这套数据比任何 profiling 工具都直观,因为你最清楚自己的代码在哪个环节加了锁、在哪个环节走了慢路径。我的经验是,靠这套计数器发现的问题,比靠代码审查发现的还要多。