先说个场外信息标题里的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_castsize_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对象内部包含一个大小类数组每个槽位是一个FreeListstruct ThreadCache { std::arrayFreeList, 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的具体代码实现那也是整个池子性能最核心的部分。