最近又把高并发内存池这个项目完整过了一遍。说实话,这类型的项目看起来就是“把内存提前准备好,等线程来取”,但真正从单线程版本改造成高并发版本之后,你会撞上一堆预想不到的麻烦:锁竞争、内存碎片、线程退出后内存回收不及时、压测数据忽高忽低……尤其当你在压测脚本里开了十几个线程循环分配释放时,标准库自带的内存分配接口会从几十纳秒飙到微秒级,一旦超过临界点,整个程序的吞吐量立刻腰斩。
这篇文章不是把代码贴一遍就完事,而是把我做这个C++高并发内存池的核心设计、关键实现、调优过程和踩坑记录完整地拆开讲。适合两类人看:一类是正在把这个项目当练手项目,想知道“到底哪些细节值得深挖”的同学;另一类是已经在工程里用高并发内存池,但遇到性能上不去、内存回收不对劲等实际问题,想找排查思路的朋友。
1. 高并发场景下,内存分配为什么会成为瓶颈
1.1 从一次压测数据说起
先说个我实测过的场景。一个多线程服务,主线程每秒钟往队列里塞任务,工作线程从队列取任务,每个任务内部会频繁 new 一些小对象。最初我用的是标准库分配器,线程数从 4 涨到 12 的时候,吞吐量不但没线性增长,反而开始抖动。用性能分析工具看了一下火焰图,发现热点并不在业务代码上,而是卡在内存分配这一段——线程之间互相等锁、等缓存区同步,时间全耗在这里了。
换成自研的内存池之后,同一套代码、同样 12 个线程,单次内存分配的时间基本稳定在几十纳秒级别,吞吐量上去了近一倍。这个对比足以说明一个问题:在并发越高、分配越频繁的场景下,内存分配器本身的效率会直接决定整个系统的上限。
你可能会问,标准库的分配器已经很成熟了,为什么还会慢?关键在于它要同时服务所有线程,天然就需要一把“全局锁”或者复杂的无锁机制来保证多线程安全。全局锁一加上,所有线程抢同一份资源,并发越高排队越久。
1.2 传统分配器的三个软肋
标准库分配器慢,并不是实现得不聪明,而是它的目标太全面了:既要兼容各种对齐规则,又要适配大小不一的对象,还要兼顾与系统内核的交互。多线程高并发环境下,它的三个软肋会同时暴露出来:
- 锁竞争。分配和释放操作会修改堆元数据,多线程并发时必须用锁保护,线程一多就排队。
- 系统调用开销。内存不够时需要向操作系统申请,而一次系统调用本身就是数百纳秒到微秒级的开销。
- 内存碎片。频繁分配和释放大小不一的对象,容易产生大量细碎的空闲块,导致明明空闲内存不少,却分配不出连续的大块内存。
高并发内存池的解决思路,是把“每个线程都要跟全局堆打交道”的模式,改成“每个线程优先在自己私有的小仓库里取内存”。只有私有仓库不够了,才去全局共享的缓冲区里拿;全局缓冲区也不够了,才向系统申请。这就像每个咖啡师手边都放了一个自己的配料盒,客人点单时直接从手边拿,不用每次都跑到后厨的大仓库里翻找。
1.3 高并发内存池的三个层次
整个项目按照“缓存分层”的思路拆成了三块:
- Thread Cache:线程私有的缓存,每个线程一个,无锁访问,负责处理小尺寸对象的分配与释放。
- Central Cache:全局共享的缓存,按桶管理定长块,负责均衡各线程之间的内存余缺。
- Page Cache:全局共享的页级缓存,管理大块内存,负责向系统申请内存,以及回收、切分、合并。
边界划分非常清晰:小对象走 Thread Cache,中对象走 Central Cache,大对象走 Page Cache。这个分层设计是我做这个项目时觉得最重要的“骨架”,也是后面所有优化和踩坑的根源。骨架搭对了,后面填功能、调性能都不会跑偏。
2. 三层缓存架构:Thread Cache、Central Cache 与 Page Cache
2.1 为什么是三层,而不是一层
一开始我也在想,线程私有缓存这一层已经能解决锁竞争了,为什么还需要 Central Cache 和 Page Cache 两层?把问题做简单一点不是更好吗?
答案是:线程私有缓存解决的是“快”,但解决不了“不均”和“回收”。假设线程 A 疯狂分配,线程 B 几乎不分配,那么线程 A 的私有缓存很快会耗尽,B 的缓存却一直闲着。如果只有一层,A 只能再次向系统申请内存,申请完也不能从 B 那里匀一张。长期跑下来,系统内存消耗会高得离谱,回收也无从谈起。
Central Cache 存在的意义,就是把各线程多出来的空闲块收集起来,借给不够用的线程。Page Cache 存在的意义,则是管理整块的内存页,负责把小块合并成大块还给系统,真正实现“循环利用”。三层各司其职,才会既快又稳。
2.2 Thread Cache:线程私有的“零锁”内存区
Thread Cache 的实现核心,是一个线程局部指针指向的缓存对象。这个对象内部维护了一个桶数组,每个桶对应一条自由链表,链上挂着相同大小的空闲内存块。
比如我用经典的内存对齐规则:小对象按 8 字节向上取整,从 8 字节到 128KB,一共规划出几十个桶。分配对象时,先按大小计算它属于哪个桶,然后从对应链表的头部取一个节点返回。释放时,把对象插回对应链表的头部。整个过程没有任何锁,因为这是线程私有的数据结构,其他线程根本看不到它。
这里最关键的动作是“头插头删”。自由链表不需要维护尾部指针,插入和删除都在头部完成,时间复杂度 O(1)。用生活类比来说,Thread Cache 就像每个人的私人抽屉,拿东西和放东西都只动抽屉口的第一件,不需要翻箱倒柜。
简单骨架可以这么写:
class ThreadCache { public: void* Allocate(size_t size); void Deallocate(void* ptr, size_t size); private: FreeList freeLists_[NumClasses]; // 按对齐规则划分的桶 };所有线程操作同一个全局的 ThreadCache 管理器,但每个线程取出来的是自己的实例:
static __thread ThreadCache* tls_threadCache = nullptr;这个__thread声明很关键,它保证了每个线程看到的都是自己独立的那一份数据,既不共享,也不需要加锁。
2.3 Central Cache:跨线程转移与定长块缓存
Central Cache 是全局共享的,线程 A 的内存多了,会还给 Central Cache;线程 B 的内存不够了,会从 Central Cache 拿。
它内部同样有桶数组,但每个桶管理的是跨度块,也就是由一页或多页组成的内存块。每个跨度块会切分成若干定长的小块,挂在自由链表上备用。
这里就必须加锁了,因为所有线程都会来访问。但锁的粒度可以做得比较细:每个桶一把锁,而不是整个 Central Cache 一把锁。这样不同桶的申请互不影响,竞争面被大幅缩小。我实测下来,细粒度桶锁对多线程吞吐量的改善非常明显,竞争从“所有人排队”变成了“只有同样尺寸需求的人可能撞车”。
Central Cache 另一个隐藏功能是批量转移。线程缓存不够时,不是一次只拿一个块,而是一次从 Central Cache 拿多个块挂在本地链表上。这个“拿多个”的操作同样需要仔细设计数量,拿太多会浪费,拿太少会导致频繁去 Central Cache 抢锁。
2.4 Page Cache:大块内存的仓库与切分逻辑
Page Cache 管理的是内存页。它的职责有四个:向系统申请连续页、按需要切分页块、把空闲页块合并成更大的页块、把再也没人用的页块还给系统。
Page Cache 内部有一张页号到内存块的映射结构,按页数挂载空闲块。当 Central Cache 需要一块n页的内存时,Page Cache 会先查找刚好n页的空闲块,如果没有,就找更大的块,切分出一部分给 Central Cache,剩下的部分继续留在空闲链上。
举个例子:Central Cache 申请 2 页,Page Cache 找到一个 6 页的空闲块,于是把它切成“2 页”和“4 页”,2 页的给 Central Cache,4 页的记录在空闲链表里,继续等下一次申请。这种切分与合并机制,正是控制内存碎片的核心。
3. 高并发安全的关键设计:桶锁、线程局部存储与自由链表
3.1 数据结构的线程安全性分析
只把结构设计成三层还不够,每一层的并发安全策略都必须明确。我梳理了一下,整个项目里有三类关键数据结构,它们的线程安全级别各不相同:
| 数据结构 | 所在层级 | 访问方式 | 并发策略 |
|---|---|---|---|
| 线程局部缓存指针 | Thread Cache | 线程私有 | 无锁 |
| 自由链表桶 | Central Cache | 多线程共享 | 每桶一把锁 |
| 页映射表 | Page Cache | 多线程读、单线程写 | 细粒度锁 |
| 跨度块状态 | Page Cache | 多线程访问 | 引用计数控制 |
线程私有的数据天然无锁,这一点是性能的基石;共享数据必须加锁,但锁要实现得足够细。我记得有一个容易犯的错误:直接在 Central Cache 的每个桶数组外面加一个大锁,结果多线程压测时依然排队严重,性能没有本质提升。
3.2 无锁设计的两条边界
不是所有地方都能做成无锁。我总结出两条边界:
第一,如果数据结构是线程私有的,永远不要用全局锁保护它。最典型的反模式是给 Thread Cache 加锁。一旦加了锁,等于全线程排队,项目的核心价值直接清零。
第二,如果数据结构是全局共享的,先用细粒度锁,不要急着追求无锁。Central Cache 的桶锁已经足够应付绝大多数场景。无锁队列听起来很酷,但 ABA 问题、内存序问题一出来,排查成本极高。
在某个模块中,我做了一个小的无锁优化:Thread Cache 向 Central Cache 批量取块时,先通过一个原子标志判断当前桶是否被其他线程占用,如果空闲就快速进入批量转移。这个优化在低竞争场景下省掉了一次锁调用,效果还可以,但代码复杂度上升不少。我的建议是,先把加锁版本跑通,再决定要不要优化这条路。
3.3 基数树映射:从指针到页号的高效索引
Page Cache 里最要命的操作是判断一个指针属于哪一页。比如释放一个对象时,调用方只给我一个void*,我需要知道它属于哪个页块、该页块当前是什么状态、要不要合并。
最直接的方案是用哈希表存指针到页块的映射,但哈希表有两个问题:内存占用大、并发扩容复杂。更好的方案是基数树。
我实现了一个三层基数树。对于 64 位系统下的指针地址,先把它右移页大小偏移量得到页号,再把页号拆成三段,每段 5 位或 6 位,逐层索引到最终的页块信息节点。三层基数树的大小固定,查询时间恒定 O(1),而且每一层只有一小块内存,整体开销很低。
关键点是:基数树的层数和每层索引位数,必须根据“实际管理的最大内存范围”来设计,而不是随便套一个深度。一开始我按 64 位地址全展开去做,发现内存占用爆炸;后来改成只覆盖当前进程实际映射范围的页号位数,一下子轻了很多。
4. 对齐规则、内存回收与碎片控制——最容易被忽略的难点
4.1 8 字节对齐:性能与利用率之间的妥协
高并发内存池里,小对象不是按实际大小来分配的,而是按对齐后的规格来分配。比如要申请 7 字节,实际拿到的是 8 字节的空间;要申请 15 字节,实际拿到 16 字节。
选择 8 字节作为最小对齐粒度,是一个经典的平衡点。如果对齐粒度太小,比如 1 字节,桶的数量会爆炸,管理开销远大于节省下来的内存;如果对齐粒度太大,比如 16 字节起步,大量场景会造成明显的空间浪费,碎片率上升。
我统计过一版数据:在典型服务场景中,对象大小大多集中在 16 到 256 字节之间,用 8 字节对齐的桶规划,空间浪费率大概在 5% 到 10% 的范围内;但如果改成 16 字节对齐,小对象浪费率会翻倍,差距肉眼可见。
4.2 回收触发条件:什么时候该把内存还回去
回收是这个项目里最容易被写坏的功能。很多人只实现了“从缓存里拿”,不实现“把缓存还回去”,程序跑起来后内存一路狂涨。
回收逻辑要分三层设计:
- 线程内部回收:释放对象时,直接放回 Thread Cache 对应桶的自由链表,无锁,瞬间完成。
- Central Cache 回收:当 Thread Cache 某个桶的空闲块数量超过一个阈值时,把一批块还给 Central Cache。
- Page Cache 回收:当 Central Cache 里某个跨度块的所有小块都被归还时,整个跨度块可以还给 Page Cache,并尝试与相邻空闲块合并。
阈值怎么定?我用的方案是“链表长度上限”,通常是这个桶规格对应的页大小与块数量的某个比例。比如一个 4 页的跨度块切成了 256 个 64 字节的小块,那么 Thread Cache 中 64 字节桶的自由链表长度尽量保持不超过 256 的一半。超过就批量归还,避免线程私有缓存无限膨胀。
4.3 切分与合并的边界细节
跨度块切分时有一个容易翻车的细节:一个页块被切给小桶规格后,剩余部分还要登记回 Page Cache 的空闲列表。如果只登记了“剩余页块”本身,没有正确更新页号范围,后续合并时会出错,甚至把两个不相邻的页当成相邻的块去合并,造成内存损坏。
合并的前提是“物理相邻”。我维护了一套页号到跨度块的映射,检查相邻页号时,如果目标跨度块处于空闲状态,就把两个块合并成一个更大的块,页数相加,重新挂回对应页数的空闲链表。这个过程不能用“地址相邻”直接判断,因为内存页必须连续才算真正相邻。
这里要多说一句:碎片的本质是内存不连续,而不是内存不够用。做这个项目时,我最开始只关注分配速度,忽略了长期运行后的碎片问题。后来压测跑了几百万次分配之后,发现总内存占用越来越高,就是合并逻辑没做好导致的。
5. 压测与调优过程:从 150 万次并发分配到性能曲线解读
5.1 压测场景怎么设计
项目做完基础功能后,我开始搭压测环境。目标不是“跑分给谁看”,而是找出系统里真正的性能瓶颈。压测脚本分三组:
第一组是单线程基准,每个线程连续完成 10 万次“分配+释放”循环,验证基本耗时和正确性。
第二组是多线程竞争,开启 8 个线程,每个线程执行 20 万次“分配+释放”循环。这能直观看出多线程下锁竞争有多严重。
第三组是混合尺寸分配,随机申请 8 字节到 4KB 不等的对象,模拟真实业务负载。分配完后不立刻释放,而是先持有相当于总内存 60% 的量,再逐步释放,观察内存回收是否正确及时。
对比对象是标准库自带的内存分配接口。压测统一启用优化编译,并把线程绑定到不同核心上,减少上下文切换带来的干扰。
5.2 压测结果与瓶颈定位
第一轮压测的数据让我既兴奋又头疼:
| 测试场景 | 标准库分配器平均耗时 | 高并发内存池平均耗时 |
|---|---|---|
| 单线程 8 字节分配 | 约 70ns | 约 22ns |
| 8 线程 8 字节分配 | 约 300ns | 约 40ns |
| 8 线程混合尺寸分配 | 约 420ns | 约 80ns |
单线程平均耗时降下来了,这是符合预期的。但 8 线程场景下,内存池单次分配只比单线程翻了不到一倍,按理说并发越大优势越明显,这本身没问题。真正有问题的是第二轮的测试:我把线程数提到 32 个,结果有些线程的分配耗时飙到了 200ns 以上,出现了明显的长尾。
火焰图看完,瓶颈出在 Central Cache 的桶锁上。32 个线程共抢同一个规格桶的锁,冲突概率指数上升,队列排队时间拉长。这时候我做了两件事:
第一,把桶锁从普通的整桶锁改成每桶一把锁加引用计数保护,让锁竞争尽量局限在极小范围。
第二,调整 Thread Cache 的批量转移数量。原来一次只取 8 个块,线程多的时候根本不够用,频繁抢锁;我改成按当前空闲列表容量动态计算,一次取走 32 到 64 个块,抢锁次数直接下降一个量级。
调完之后,32 线程场景的 P99 延迟从 230ns 降到了 90ns 左右,效果非常显著。
5.3 调优时的两个关键权衡
调优过程中我一直在权衡两组矛盾:
一组是本地缓存容量与全局内存占用。Thread Cache 容量设得越大,线程抢中央锁的次数越少,性能越好;但每个线程都囤一大堆闲置内存,整体内存占用会变得非常高。我最终取了一个“让每线程缓存量大约等于一周高频对象的用量”的经验值,既能扛住瞬时高峰,又不会囤太多。
另一组是分配粒度与碎片率。把页块切得越细,小对象分配越灵活,但合并回大块时越困难;切得越粗,大块分配方便,小对象却会浪费大量空间。这个只能根据实际场景去测,不能靠拍脑袋。
调优到 60% 满意之后,我就停了。因为这个项目更适合用来理解内存分配的本质,而不是无限追求极限性能。继续压榨下去收益降低,还会把代码改得难以维护。
6. 踩坑实录与常见的四个隐蔽问题
6.1 自由链表指针越界
这个坑出现在自由链表实现初期。我用对象内存块的前若干个字节来存储next指针,这意味着内存块必须足够大,至少能装下一个指针。8 字节以上的块都没问题,但当时我把最小对齐粒度定成了 4 字节,结果 4 字节的块里塞next指针直接越界,数据全被写坏了。
排查思路很直接:用调试工具跑一遍单线程压测,在 Debug 版里开启内存访问越界检查,很快定位到越界发生在自由链表头插操作。修复方案是把最小对齐粒度提升到 8 字节,保证任何一次分配都至少能装下指针。
6.2 内存泄漏:VSS 疯涨,RSS 却不动
有一版代码跑长任务,我用工具监控内存,发现虚拟内存占用(VSS)一路飙升,但真正占用的物理内存(RSS)维持不动。这个现象说明什么?说明内存池把页面申请到进程空间后,并没有真正把归还逻辑执行到位。
我当时反复检查 Page Cache 的回收条件,发现线程退出后,它的 Thread Cache 对象没有挂在全局回收链表上,导致线程私有的内存块没人处理,全部滞留。修复方案是注册线程退出回调,把该线程所有的 Thread Cache 空闲块合并归还到 Central Cache,再由 Central Cache 判断跨度块是否可以被 Page Cache 回收。修完后再跑 24 小时长任务,内存曲线平稳多了。
6.3 对象释放时对错桶
这是最容易隐蔽的 bug:释放对象时直接把指针丢回链表的头部,没有按对象大小重新计算桶编号。因为有些对象在分配时可能是从 Central Cache 那边的对齐补偿块里取出来的,实际可用的块大小大于请求大小,如果简单按“原始请求大小”归桶,会把一个 17 字节的对象放回 16 字节的桶。表面上链表不会断,但后续分配 16 字节时拿到一个实际只有 16 字节边界约束的地址,极易踩到越界。
正确做法是释放时必须明确记录每个指针实际所属的桶编号。我在 Thread Cache 的对象头部增加了一个小标记,释放时先读标记再归桶,彻底避免了对错的可能。
6.4 页缓存里“幽灵页”的引用计数问题
Page Cache 合并逻辑里,我最初只记录“当前是否空闲”,没有考虑“是否有外部引用”。一个页块被 Central Cache 借出去后,Page Cache 侧仍然认为它是空闲的,结果另一个线程又把它分配走了,两块数据互相踩踏。
修复方案是给每个页块加一个引用计数:被 Central Cache 持有的页块计数加一,归还时减一,只有计数归零才允许参与合并。这个改动让系统的稳定性提升了一个档次,否则多线程压测时,隔三差五就会出现莫名其妙的段错误。
6.5 关于自检的一个建议
如果你也在实现这个项目,我强烈建议在写完功能后先写一个“内存压力自测程序”:开 16 个线程,每个线程不断分配释放随机大小的对象,持续跑 10 分钟,期间监控物理内存和虚拟内存曲线。能平稳扛住这个测试,说明回收、合并、引用计数这三个核心环节基本都没问题。否则大概率是上面四个坑里的某一个。
7. 最后分享一个实际操作中的体会
做完整个项目,我最深的体会是:高并发内存池的价值不只在“快”,更在“可控”。标准库分配器虽然好用,但你永远不知道它什么时候会触发系统调用,什么时候内存碎片会悄悄积累;而自己实现的池子,每一层缓存的状态、每一次回收的时机都在掌控之中。这种掌控感,对排查线上疑难问题极其有帮助。
如果你要动手做,我的建议是分四步走:先实现 Thread Cache,跑通单线程分配释放;再实现 Central Cache 的批量转移,跑通多线程共享访问;接着实现 Page Cache 的切分与合并,跑通大块内存管理;最后再加上回收机制和引用计数。每一步都配一个小的压力测试,跑稳了再进入下一步。
在做这个项目的过程中,你踩过的每一个坑,实际上都是在建立对内存管理系统的直觉。这种直觉是看多少博客、读多少文档都换不来的。至少对我来说,写这篇文章时回顾那些 Bug 带来的思考,依然比单纯看分配速率从多少纳秒降到多少纳秒更有价值。