先说个场外信息:标题里的FastAlloctor是我建目录时的拼写笔误,正确拼写是FastAllocator,写都写了就干脆沿用下来,这个系列后续统一用FastAllocator。这个项目是一个从零手写的C++高性能内存池,目标是在频繁的小对象分配与释放场景下,接管malloc的工作,把分配延迟和内存碎片压下去。为什么想折腾这件事?上个月我在一个消息转发组件里做性能分析,发现单次请求处理要执行两万多次小对象分配,malloc/free占了将近四成的时间,还伴随肉眼可见的内存碎片增长。当时我就决定,与其在代码里到处做对象缓存、堆临时优化,不如做一个统一的内存池,把以前项目里踩过的坑系统性解决一遍。这篇是系列第一篇,先讲清楚项目背景、总体架构、核心数据结构和设计取舍,具体实现代码从下一篇开始贴。
1. 为什么自研内存池:malloc的瓶颈到底在哪里
1.1 真实项目里看到的三个痛点
先说我在具体项目里观察到的三个问题。第一个是调用开销,一个几十字节的小对象,malloc和free要进入全局分配器状态,涉及空闲链表、堆元数据的读写。单次操作几十纳秒听起来不多,但业务请求分配次数一旦上去,累积起来就非常可观。我上个月分析的那个组件,单次请求生命周期里会创建大量生命周期极短的临时对象,绝大部分只有几十字节,这些对象每经过一个模块就要new/delete一次,malloc被调用的频率高得惊人。
第二个痛点是内存碎片。程序跑得越久,空闲块分布越散,后续分配一个稍大的对象时,明明总空闲内存足够,却找不到连续空间。最直接的表现是常驻内存只增不减,重启后才恢复。碎片问题又分两种:内部碎片是分配比请求大的块导致的浪费,外部碎片是空闲块被切割得七零八落。malloc它们不是不做整理,而是通用分配器的策略必须照顾各种大小,天然没办法针对固定尺寸场景做最优分割。
第三个痛点是多线程竞争。最基础的实现里,所有线程共享同一组空闲链表,并发分配时每次都要加锁或CAS,线程越多争抢越激烈,吞吐量会明显下降。我们在压测里看到过8线程并发分配时,malloc的吞吐量只有单线程的3倍左右,远达不到线性扩展,这就是共享状态锁竞争造成的。
这三个痛点在特定业务场景里叠加起来,结论非常清晰:需要一种为固定尺寸小对象优化的分配方式,尽量减少共享状态访问,让内存块可以按尺寸回归和复用。
1.2 先用量化数据说服自己
动手之前我建议先做一次基准测量,不要靠感觉决定。我的做法是写一个非常简单的分配/释放循环,类似这样:
for (int i = 0; i < 1000000; ++i) { void* p = std::malloc(64); std::free(p); }在普通台式机上,单次malloc加free大约40到50纳秒。如果换成一个线程本地缓存池,理想情况下单次分配加释放可以压到5纳秒以内。一百万次循环,差距就是四五十毫秒;放到每秒处理上万请求的服务里,这个差距会被放大成秒级的不同。
我还做了一个更贴近业务的测试:模拟60%的64字节分配、20%的128字节分配、10%的256字节分配,以及少量大块分配。malloc在混合尺寸下表现还行,但分配模式变得随机后,吞吐下降明显。而一个按尺寸分类的内存池,固定尺寸路径可以做到几乎只做一次指针弹栈,复杂度完全不可比。
不过也要泼一盆冷水:如果你的业务分配次数很少,或者内存都集中在启动阶段一次性创建,完全没有必要自研内存池。系统malloc本身已经经过大量优化,瞎折腾只会增加维护成本。做自研池的前提是,profiling数据明确告诉你分配器是瓶颈,并且分配模式适合池化。
2. 总体架构:三层分级设计
2.1 为什么是三层:线程缓存、中央缓存、页缓存
我的整体设计借鉴了经典开源内存池的分层思路,但针对自己的业务场景做了取舍。整体分成三层:
- 线程缓存(Thread Cache):每个线程私有一组FreeList,按大小类组织空闲块。正常分配时直接从线程本地取,完全无锁,这是性能的核心来源。
- 中央缓存(Central Cache):全局共享的一组CentralFreeList,负责给线程缓存批量补充空闲块,也接收线程缓存归还的块。这里有一把锁,但临界区非常短。
- 页缓存(Page Cache):真正向操作系统申请大块内存的地方,以页为单位管理Span,负责大块内存的切分、合并和回收。
为什么不做成简单的全局池?因为全局池加锁会导致所有线程踩踏同一个临界区。三层结构把高频路径隔离在线程缓存里,锁只出现在批量和低频操作上,是一种以空间换性能的典型设计。
用生活化的方式理解:这就像公司茶水间,每个人工位放一个零食盒,平时自己取用,空了才有人批量去大仓库补货。而不是每次想吃零食都要跑到大仓库排队。
2.2 设计原则与关键取舍
有几个设计原则我从一开始就定了,后续实现都会遵守。
第一,按大小类分级。内存块尺寸先按对齐值归到不同档位,每个档位对应一条FreeList。比如8字节对齐下,8、16、24、32字节各占一条链。档位设计让同样大小的块放在一起,减少了内部碎片,也让分配逻辑变成简单的数组索引。
第二,分配单位是页。页缓存以页为粒度管理内存,默认一个页是4KB,多个连续页组成一个Span。低于某个阈值(我暂定256KB)的请求走池化路径,超过阈值直接向系统申请,不参与池化。因为大块分配通常频率低,不值得为它们维护复杂结构。
第三,一个Span只服务一个大小类。这是控制碎片最核心的一条,某个Span从创建到归还只负责固定档位的对象分配,绝不混用。多亏了这条约束,对象归还时才能通过地址反查Span,精确放回同一链表。
还有一个不太直观的原则:池子里的内存不能无限增长。CentralFreeList的容量要设上限,线程缓存也要设容量上限,否则多线程高峰一波接一波,空闲块会在线程缓存里堆积,内存占用无节制上涨。真正的平衡点是"够用就好,不够再批量取"。
这层设计的锁策略可以总结成一张表:
| 层级 | 并发模型 | 锁粒度 |
|---|---|---|
| 线程缓存 | 线程私有,无锁 | 无 |
| 中央缓存 | 全局共享,批量转移 | 一把短临界区小锁 |
| 页缓存 | 低频申请与合并 | 一把大锁,可接受 |
3. 核心数据结构:从大小类到Span管理
3.1 尺寸映射表怎么设计
内存池最基础的操作,是给定一个size,快速算到对应的档位index。这个操作要么查表,要么位运算。我选择位运算,因为路径短且不需要额外内存访问。8字节对齐下的核心代码大致这样:
inline size_t Align8(size_t size) { return (size + 7) & ~static_cast<size_t>(7); } inline size_t GetClassIndex(size_t size) { // 假设size大于0,返回第几个槽 size_t aligned = Align8(size); return aligned / 8 - 1; }这里的代价是内部碎片:请求5字节实际拿8字节,浪费3字节;请求9字节实际拿16字节,浪费7字节。换来的是O(1)定位和数组化管理,非常划算。
8字节对齐在多数平台上够用,但如果业务大量使用SIMD指令,16字节或32字节对齐是硬需求,这时需要把对齐参数做成可配置的。还有更精细的做法是分段映射:1到64字节按8对齐,65到256按16对齐,再往上按32或64对齐。段式映射能减少大块浪费,但实现复杂度明显上升。当前版本我保持线性对齐,先把正确性跑通,后续用真实使用数据决定要不要改。
3.2 Span结构和页的状态管理
Span是连接页缓存和中央缓存的枢纽,代表一组连续的物理页。核心字段设计如下:
struct Span { u64 start_page; // 起始页在全局页表中的下标 u32 npages; // 连续页数 u16 size_class; // 该Span服务的大小类,0表示大块走系统 u8 state; // FREE / USED / NORMAL Span* next; // 双向链表 Span* prev; };为什么要专门记录start_page和npages?因为页缓存做Span合并时,需要判断两个相邻Span是否可以拼成一个更大的Span。合并的前提是先知道邻居的起始页和状态,单靠链表是拿不到这些信息的。另外,当一个Span释放回页缓存时,页缓存要根据页id反查到起始地址,然后才能执行madvise或置回空闲链表。
这里引出了一个关键组件:页表。全局维护一张从页id到Span的映射表,回收对象时,先用对象地址算出页id,再查到对应Span,从而把对象放回正确的大小类链表。页表本身也有内存开销,最粗糙的做法是开一个巨大的数组,但内存池的地址空间范围可能很大,一个数组往往不现实。更好的做法是做成多级索引结构,按需分配节点,大部分小对象都落在浅层节点上,查询开销极低。
3.3 线程缓存和内存序
线程缓存的实现主体是thread_local对象,内部包含一个大小类数组,每个槽位是一个FreeList:
struct ThreadCache { std::array<FreeList, kMaxClassCount> free_lists_; size_t allocated_bytes_; // 从中央缓存申请的总字节数 size_t central_return_bytes_; // 归还给中央缓存的总字节数 };线程缓存本身不需要加锁,因为同一线程独占访问,链表的push/pop都是普通操作。真正的同步发生在跨线程数据转移时:中央缓存给线程缓存批量搬运空闲块,这里必须保证先写入,后可见。对应到C++内存模型,就是release语义写和acquire语义读的配对。
不过我要提醒一句,别为内存序过度焦虑。普通对象池的正确性主要靠锁或原子计数,真正需要精细设计内存序的地方在无锁队列和跨线程判断上。拿不准的时候先用mutex,把性能数据拿到,再决定要不要优化。无锁代码调试成本高,得不偿失的情况我见得太多了。
4. 项目目标与验收标准
4.1 交付节奏和接口设计
我把整个项目拆成三个阶段。阶段一实现单线程下的基础池,用简单锁保护,功能正确,把分配、释放、归还路径全部打通;阶段二引入线程缓存和中央缓存,解决多线程竞争问题;阶段三实现页缓存、Span合并、自动碎片整理,并把基准测试脚本固化成回归工具。
接口设计直接决定这个池子好不好用。我只负责原始内存块的分配和释放,不管对象的构造和析构,这不是容器allocator,而是底层内存提供者。对外接口保持极简:
class FastAllocator { public: void* Alloc(size_t size); void Free(void* ptr); void* Realloc(void* ptr, size_t new_size); // 阶段三再支持 };一个重要的约定是:Alloc返回的地址必须保证16字节对齐,这样基本类型、vector、SIMD都能直接用。为了调试,可以在分配模式里给每个对象头部加一些元数据,比如对象大小、分配点信息;生产模式则关闭这些额外开销,避免影响性能。
4.2 Benchmark方案和性能目标
没有验收标准的项目容易无限做下去。我定的主要性能对标场景就是高频小对象混合分配与释放,同时叠加多线程压力。测试流程必须足够严谨,先随机分配释放预热一秒钟,再统计真实吞吐量,避免冷启动缓存和分配器初始状态干扰结果。
尺寸分布模拟真实业务:约60%是64字节请求,20%是128字节,10%是256字节,剩下10%是大块请求走系统分配。线程数从1到8都测,每个线程独立执行随机尺寸的分配释放循环。
我给自己定的验收标准是这样的:
- 单线程吞吐量至少是malloc基准的2倍
- 8线程吞吐量至少是malloc基准的3倍
- 池子额外内存开销(管理结构、页表、空闲块占用总量与分配总量之比)控制在5%以内
- 混合尺寸压力运行10分钟后,碎片率不出现明显上涨
这些数字不是拍脑袋定的。研究过经典实现后,小对象分配理想的整体延迟在几个纳秒到十几纳秒之间,我的目标是让单次分配+释放进入这个区间,同时保持至少和malloc同等级的正确性预期。
5. 踩坑记录与设计取舍复盘
5.1 最容易翻车的高危细节
写内存池这种系统级代码,翻车往往在非常不起眼的地方。第一个坑是内存对齐。8字节对齐看着够用,某些SIMD指令要求16字节甚至32字节对齐,一旦返回地址不对齐,运行期会直接crash,而且出错位置离分配点很远,非常难排查。设计上必须把对齐值做成模板参数或可配置项。
第二个坑是空闲链表的指针复用。一个对象分配出去后,它所占内存完全归用户所有;被归还后,内存池会在同一位置写入下一个空闲块的指针。这个机制本身很高效,但要求内存池绝对不能访问尚未归还的对象头部,否则用户数据就被破坏了。我在早期版本踩过类似的坑,调试期花了很长时间,后来学乖了,默认开启magic number校验,生产模式再关闭。
第三个坑是地址反查的重要性。Free对象时,通过指针找到对应页、再找到对应Span,这条路径一旦失效,轻则对象放错大小类,重则链表损坏死循环。尤其是用户不小心双重释放或者越界写,很容易破坏FreeList的链头,表面症状是随机崩溃,实际原因是内存被写穿。排查这样的问题,唯一有效的手段就是调试期加强校验,别指望从堆栈里一眼看出来。
5.2 三个有争议的设计决策
第一个争议是向系统申请内存的方式。可以直接mmap一块大内存,让池子自己切分;也可以先malloc一大块再内部管理。我选择了在页缓存里统一用mmap管理,因为这样可以让大块内存具备释放归系统的能力,配合madvise等接口做真正的归还。考虑到跨平台,我会把这一层封装成可替换的实现,在部分平台上退回到malloc包一层也能跑。
第二个争议是大小类映射方式。线性对齐简单直观,但大尺寸区域内部碎片偏高。专业实现里更常见的是分段映射,小尺寸区密集分档,大尺寸区稀疏分档。现阶段线性方案够用,而且可以更早把整个流程跑通,等拿到真实碎片数据再迭代不迟。
第三个争议是自动缩容。我初期版本决定不做线程缓存的自动归还,也就是说线程缓存空了也不会主动把自己大块内存归还给中央缓存,更不会还给系统。原因是:高并发服务常常一会低峰一会高峰,刚缩容完,下一秒高峰来了又得重新申请,反而造成性能抖动。更合理的做法是设置线程缓存的容量上限,到了上限就不再从中央缓存批量取块,而不是频繁释放内存。
写这个项目的过程中,我最深的体会是,内存池这类工程的真正难点不在写代码,而在记录和验证每一个设计决策。每一层的划分、每一个数据结构的字段、每一次锁粒度调整,背后都要有至少一个可量化的原因。如果不把动机记下来,后期调优时根本不知道该动哪里,甚至会把一个正确的优化改回去。
如果你正在犹豫要不要自研内存池,我的建议是先跑一次profiling,真正确认分配器是瓶颈,确认业务分配模式适合池化,再动手。如果只是偶尔分配大块对象,系统malloc完全够用,别为了技术兴奋点给自己造一个维护负担。
这个系列我会按照设计、实现、调优的节奏往下写,下一篇直接进入线程缓存和FreeList的具体代码实现,那也是整个池子性能最核心的部分。