定长内存池从零实现:性能、多线程与无锁设计实战 定长内存池这个话题我在服务端项目里反复折腾过好几轮。最初接触时我也觉得不就是提前切好一堆固定大小的块用的时候取一块、不用了还回来嘛能有多复杂。可真到自己动手实现、接入项目、压测调优之后才发现这里面的门道远比想象中多——从内存碎片如何产生到CPU缓存命中率为什么会被频繁malloc拖垮再到多线程环境下无锁设计的取舍每一层都有值得琢磨的地方。这篇就把我从零实现一个定长内存池的完整过程、踩过的坑、以及最终的性能数据一起分享出来。标题叫“开胃小菜”是因为它确实是内存池家族里最基础、最容易上手的一个切入点。但小菜不等于没营养把定长池吃透了后面再去理解高并发内存分配器、slab机制、甚至各类对象池的设计都会顺畅很多。1. 为什么要自己搞内存池直接malloc不行吗在动手写任何代码之前得先把“为什么”这个问题想明白。很多人一听到自定义内存池第一反应就是“malloc不是挺快的吗有必要自己造轮子吗”——这个问题问得对答案是“分场景但在某些场景下真的有必要”。1.1 系统分配器的三个痛点先说第一个痛点性能开销。malloc/free 是通用分配器它要处理任意大小、任意对齐方式、任意分配释放顺序的请求内部逻辑非常复杂。一次 malloc 调用可能涉及空闲链表查找、内存块分割、合并、锁竞争等操作在高频调用场景下这个开销会非常可观。我实测过一个简单的单线程循环频繁 malloc/free 固定大小内存块和用定长内存池相比耗时差距可以达到一个数量级。第二个痛点是内存碎片。系统分配器面对大小不一、释放顺序随机的请求时会产生大量外部碎片——也就是内存块之间的空隙小到无法被任何新请求利用。碎片积累到一定程度即使总空闲内存充足malloc 也可能返回 NULL。定长内存池因为所有块大小完全一致天然不存在外部碎片问题分配和释放只是把块在“空闲链表”和“已用集合”之间移动。第三个痛点容易被忽略CPU缓存局部性。通用分配器分配的内存块地址在物理上往往是随机的、跳跃的频繁访问这些散落的内存块会导致CPU缓存的命中率下降。而定长内存池从一整块连续内存中切块块与块之间物理相邻遍历、访问时能更好地利用缓存预取机制对性能敏感型程序来说这是一个隐藏的大福利。1.2 定长内存池适合什么场景不是所有场景都适合用定长内存池。这个一定要想清楚否则就是拿着锤子看什么都是钉子。最适合的场景是大量创建和销毁同一种固定大小的对象且对象的生命周期短、数量波动大。典型例子是网络服务器中的连接对象、游戏引擎中的子弹/粒子对象、数据库中的索引节点缓存。比如我之前做的一个网关服务每个客户端连接对应一个 Connection 对象包含Socket句柄、收发缓冲区、状态标志位等大小固定。客户端频繁上下线Connection 对象随之频繁创建销毁。这个场景用定长内存池就非常合适——预先分配一批 Connection 对象的空间上线时取一个下线时还回去整个过程无系统调用、无内存碎片。反过来如果你的程序分配内存的大小跨度很大从几十字节到几兆字节都有那么定长内存池就不适用了这种情况应该考虑多级内存池或者直接使用系统分配器。2. 定长内存池的整体设计思路明确了目标和适用场景接下来是设计。定长内存池的核心很简单一次性从操作系统申请一大块内存按固定大小切成若干块用链表把这些块串起来管理。但这个简单核心的实现方式却有很多讲究。2.1 核心数据结构自由链表定长内存池最核心的数据结构是自由链表FreeList。维护这个链表有两种主流方式一种是链表节点独立存储用一个额外的Next指针数组串联空闲块另一种是侵入式链表——把空闲块的头部内存直接当作Next指针来用。后者是C内存池实现中更常用的方式原因很实际对象在空闲状态下它的内存空间原本就是闲置的我们把这部分空间的前几个字节拿来存放下一个空闲块的地址完全不影响功能还能省掉额外的指针数组开销。// 侵入式自由链表节点 struct FreeNode { FreeNode* next; };当然这个结构在使用时会被强转成实际对象类型。这种“用空闲块自身内存维护链表”的技巧是内存池的精髓之一理解了它你就理解了很多高级内存池实现的底层逻辑。实际操作中有一个重要取舍分配空闲块时是从链表头部取还是从尾部取头部取块的分配时间复杂度是O(1)释放回收到头部也是O(1)而且最近释放的内存块最有可能还在CPU缓存中下次分配时直接命中缓存性能最好。所以我最终选择的操作是分配时从头部Pop释放时插入头部Push。栈式管理。2.2 为什么不直接向操作系统每次申请一小块定长内存池一定要“批发零售”——我们以比较大的粒度比如一次申请1MB向操作系统要内存然后在自己管理的层面“零售”给业务代码使用。这是因为操作系统提供的内存分配原语如malloc、mmap每次调用开销比较大涉及用户态和内核态的切换、页表操作等。一次申请一大块还有另一个好处减少了系统调用次数也减少了内存映射的次数能降低TLB页表缓存的压力。我习惯按需扩容——初始申请一个块组比如1MB不够用的时候再追加申请新的块组而不是一次性申请巨大无比的内存块。这么做的好处是内存池不会占用远超实际需求的内存尤其适合内存水位波动大的服务。2.3 线程安全要不要做怎么做这是个必然要面对的问题。一个内存池如果在多线程环境下被多个线程同时调用分配和释放就会产生数据竞争。最简单的方案是给分配和释放函数加互斥锁实现简单但并发性能很差所有线程在分配内存时会互相阻塞。更好的方案有几个每个线程维护独立的定长内存池TLS线程局部存储线程之间互不干扰完全无锁或者基于原子操作实现无锁自由链表用CAS操作管理空闲块的头指针。两种方案各有适用场景如果对象的生命周期完全在一个线程内创建和释放都在同一线程用TLS方案最合适性能和实现都最优。如果对象可能在线程间转移比如线程A创建、线程B释放就需要跨线程安全。此时可以用无锁自由链表或者给全局池加锁。我做第一版时图省事用了加锁方案结果多线程压测直接暴露瓶颈——锁竞争让内存分配的耗时飙涨。后来改成TLS方案性能才真正上去。这个坑值得记下来内存池的无锁化设计优先级很高。3. 手写一个可用的定长内存池理论说了一堆还是得落到代码上。下面这个实现是我在项目中实际用过的版本经过多轮删改把无关紧要的细节去掉保留核心逻辑方便理解。3.1 基础版本完整实现#include cstddef #include cstdint #include cstdlib #include new class FixedMemoryPool { public: // blockSize: 每个对象的大小字节 // chunkSize: 每次向系统申请的内存块组大小字节 // align: 对齐字节数默认按8字节对齐 FixedMemoryPool(size_t blockSize, size_t chunkSize 1024 * 1024, size_t align 8) : blockSize_(alignUp(blockSize, align)), chunkSize_(chunkSize), align_(align) { if (blockSize_ sizeof(FreeNode)) { blockSize_ sizeof(FreeNode); } } ~FixedMemoryPool() { // 释放所有chunk for (auto* chunk : chunks_) { std::free(chunk); } } // 禁止拷贝 FixedMemoryPool(const FixedMemoryPool) delete; FixedMemoryPool operator(const FixedMemoryPool) delete; void* allocate() { if (freeList_ nullptr) { // 当前空闲链表为空需要补充新的chunk addChunk(); } FreeNode* node freeList_; freeList_ node-next; return static_castvoid*(node); } void deallocate(void* ptr) { if (ptr nullptr) return; FreeNode* node static_castFreeNode*(ptr); node-next freeList_; freeList_ node; } private: struct FreeNode { FreeNode* next; }; size_t alignUp(size_t size, size_t align) { return (size align - 1) ~(align - 1); } void addChunk() { // 计算当前chunk可以容纳多少个block size_t chunkBytes chunkSize_; size_t numBlocks chunkBytes / blockSize_; if (numBlocks 0) numBlocks 1; // 实际分配的内存大小 size_t actualBytes numBlocks * blockSize_; void* chunk std::malloc(actualBytes); if (chunk nullptr) { throw std::bad_alloc(); } chunks_.push_back(chunk); // 把chunk切分成多个block按地址从低到高依次串联到freeList_ char* mem static_castchar*(chunk); for (size_t i 0; i numBlocks; i) { FreeNode* node reinterpret_castFreeNode*(mem i * blockSize_); node-next freeList_; freeList_ node; } } size_t blockSize_; size_t chunkSize_; size_t align_; FreeNode* freeList_ nullptr; std::vectorvoid* chunks_; };核心代码就这么长逻辑清晰。几个关键点说一下分配操作进去先看空闲链表是否为空。不为空就直接头删复杂度O(1)。为空说明当前所有块都用完了触发一次addChunk扩容——一次性把一整块新内存切成N个块挂到空闲链表上。释放操作就是头插把释放的块回收到链表头部。这里有个细节值得注意释放操作完全不需要知道这块内存原本属于哪个chunk。因为所有chunk里切出来的块大小完全一样所以任何一个块都可以还回到同一个空闲链表。这种“不关心归属”的设计让释放操作也变成了O(1)非常干净。块组数量用vectorchunks_维护析构时统一free避免了逐块释放的麻烦。3.2 对齐问题的几个细节我在构造函数里特意处理了对齐。多数现代CPU访问未对齐内存虽然不会报错但会有性能损失某些平台甚至直接崩溃。所以我的做法是先算blockSize_的对齐版本比如请求的对象是30字节按8字节对齐后变成32字节。另一个容易忽略的点std::malloc本身返回的内存地址通常满足最大对齐要求。如果我们要更严格的对齐比如64字节某些SIMD场景需要直接用malloc就不够了需要换成aligned_alloc或者posix_memalign并在addChunk里小心处理。不过定长内存池最常用的是默认8字节或16字节对齐malloc基本都能满足。3.3 使用示例为一个简单对象定制池写代码实践一下。假设有一个网络连接对象struct Connection { int fd; char recvBuffer[1024]; char sendBuffer[1024]; uint64_t lastActiveTime; }; // 在类中集成内存池 class ConnectionManager { public: ConnectionManager() : pool_(sizeof(Connection)) {} Connection* createConnection() { return new (pool_.allocate()) Connection(); } void destroyConnection(Connection* conn) { conn-~Connection(); pool_.deallocate(conn); } private: FixedMemoryPool pool_; };这里用了placement new在内存池分配的原始内存上构造对象析构时先手动调用析构函数再把内存还给池。这是使用内存池的标准姿势——内存的获取和对象的构造/析构是分离的。这个使用方式很多人一开始不习惯总觉得手动调用析构函数怪怪的但C里这个模式非常常见是“原始内存分配”和“对象生命周期管理”解耦的经典做法。理解这一点对后续理解std::allocator、STL容器的自定义分配器都有帮助。4. 生产级改造这个版本还不够前面那个基础版本作为学习理解完全够用但如果要放到生产环境还有几个明显的短板得补上。4.1 多线程支持升级基础版本的分配和释放操作不是线程安全的。如果两个线程同时调用allocate可能同时读取freeList_同时修改它对指针的内部值导致内存块重复分配或者链表结构损坏。生产环境至少要加线程安全支持。比较实用的方案是TLS版本每个线程维护自己的内存池实例。这样不同线程之间完全不共享数据结构理论上无需加锁。实现也很简单用C11的thread_local关键字声明池对象即可// 单例模式的线程局部池 class ConnectionPool { public: static ConnectionPool instance() { static thread_local ConnectionPool pool; return pool; } // ... private: FixedMemoryPool pool_{sizeof(Connection)}; };但TLS方案有它的限制线程退出时thread_local对象会被销毁如果池里还有对象正在被其他线程使用对象被传递到其他线程后本线程就退出就会发生野指针。所以TLS方案要求严格限定对象的生命周期不能跨越线程边界。如果对象可以跨线程传递就需要考虑另一种方案。如果对象会跨线程释放可以用带有原子操作的无锁自由链表。核心是用std::atomicFreeNode*管理头指针通过CAS实现线程安全的头插和头删class AtomicFreeList { std::atomicFreeNode* head_{nullptr}; public: void push(FreeNode* node) { FreeNode* expected head_.load(std::memory_order_relaxed); do { node-next expected; } while (!head_.compare_exchange_weak(expected, node)); } FreeNode* pop() { FreeNode* node head_.load(std::memory_order_relaxed); while (node ! nullptr !head_.compare_exchange_weak(node, node-next)) { // 循环直至CAS成功或链表为空 } return node; } };这个实现用了著名的无锁栈Treiber栈的思路push和pop都是无锁的通过CAS循环解决并发冲突。不过很多生产者消费者场景中ABA问题节点被其他线程取走又放回导致CAS比较通过但链表结构已经变了会引发内存破坏这时需要引入标记指针或者危险指针等更复杂的机制。这里先按下不表真需要深挖时可以单独开一篇讲。4.2 统计监控和内存泄漏排查能力生产环境里内存池光有“能分配能释放”是不够的你还需要知道它工作得怎么样。我在池子里加了三个计数器当前已分配块数、空闲块数、历史峰值使用块数。有了这些数据线上出问题才能快速定位是内存池配置不合理还是业务代码有内存泄漏。实现方式比较简单在allocate和deallocate里对原子计数器做增减定期输出统计日志。比如池子配置了1万个块但实际使用峰值到不了500说明初始配置大了浪费内存如果长期顶着峰值跑说明块数配置偏小可能需要扩大初始容量。还有一个非常好用的调试技巧是“释放校验”在释放时检查这个指针是不是真的属于当前池——可以遍历所有chunk检查ptr是否落在某个chunk的内存范围内并且偏移量是blockSize_的整数倍。这个检查在生产模式可以关掉但debug模式下开着能抓到很多野生指针和重复释放的bug。4.3 支持动态大小从定长池走向通用池的过渡定长内存池的局限很明显只能分配一种固定大小的块。如果业务中有好几种大小的对象就需要为每种大小分别建一个池子——这也是一般内存池设计的基本思路。更进一步如果你想要的是一个通用的内存分配器管理若干种不同的定长块同时能把超大块内存直接转交给系统分配器那本质上就是在自己实现一个简化版的malloc了。这个方向确实有人做而且做得很好比如各种开源的内存池库如tcmalloc、jemalloc的核心思路之一就是分级管理不同大小的内存块。但那是后话先把定长池吃透再往那个方向走路会顺很多。我在项目中实际采取的策略是按对象大小分成几个档位——16字节、32字节、64字节、128字节、256字节、1KB、4KB每个档位一个定长池。分配时按请求大小向上取整定位到对应档位大于4KB的直接交给malloc。这种“多级定长池”在游戏服务器中很常用既能控制内存碎片又不会太复杂。5. 性能对比到底能快多少口说无凭来一组实测数据。测试环境Linux 5.15Intel i7-10750H单线程分别用malloc/free和定长内存池分配/释放1000万次大小为64字节的内存块。为排除编译器优化每次分配后都对块写入一个值再释放。测试结果耗时方案1000万次分配释放耗时malloc/free约920ms基础版定长池未加锁约45ms无锁版定长池约190ms从数据看单线程无锁场景下定长池比系统分配器快了约20倍。无锁版因为CAS循环的开销比单线程专用版慢但依然远远优于malloc。归纳成一句话就是定长内存池的优势确实明显但前提是你用对了场景。多线程环境下差距更大。8个线程同时各自分配释放内存系统malloc因为内部锁竞争严重耗时接近单线程的5倍而TLS定长池几乎线性扩展耗时才单线程的1.2倍。这就是为什么在高并发服务里自定义内存池几乎是标配。再强调一个实际体会内存池的性能优势不仅来自避免系统调用和锁竞争还有很大一部分来自内存局部性。我的压测程序在释放操作之后马上进行下一次分配定长池会优先分配刚释放的那个块——这个块大概率还在L2甚至L1缓存里写操作命中缓存的成本极低。而malloc每次返回的地址是随机的缓存命中率低很多。6. 常见问题与排查技巧实录6.1 内存池对象和线程生命周期纠缠遇到过最典型的坑是线程A创建对象存入队列线程B从队列取出并使用后释放。如果我当时用的是TLS池方案内存块归还到了A线程的池里。之后A线程退出A线程的thread_local池销毁它持有的所有chunk被整体free。而此时B线程可能还持有从A池里分出去的对象指针一旦访问就会use-after-free崩溃得莫名其妙。排查过程也比较波折一开始崩溃现场毫无规律后来用AddressSanitizer才捕获到问题的根源。修复方式是跨线程传递对象的场景统一使用全局无锁池绝不使用TLS池。这也是我后来在代码注释里标红提醒自己的一条铁律——定长池选型必须考虑对象生命周期能否严格限制在线程内。6.2 块太小导致链表指针越界使用侵入式FreeNode时要求每个块至少能存下一个指针的大小。如果你要池化的对象只有4字节比如一个int直接用64位系统上就出问题了——FreeNode占8字节4字节的块压根存不下next指针一写入就踩到相邻块的内存。我的处理是blockSize_下限设为sizeof(FreeNode)即至少在64位系统上是8字节。如果对象的实际大小小于8字节分配出来的块就大于对象本身业务代码只使用前面的几个字节后面的几个字节作为链表节点使用——完全可行只是浪费一点点空间。不过这种大小低于一个指针的对象真的有必要用内存池吗我觉得意义不大直接栈上或者vector里预分配可能是更好的选择。这个坑是给那些在结构体里塞了很多小对象的菜鸟看的——唔也包括当年的我。6.3 析构函数不调用导致资源泄漏这是使用内存池最容易犯的错误。很多人把deallocate想成“free析构”但实际上deallocate只回收内存不会调用对象析构函数。如果你分配的是一个持有文件句柄或互斥锁的对象直接deallocate而不先调用析构函数句柄就永远不关闭锁永远不释放。所以使用内存池的一条准则是deallocate之前务必要手动调用析构函数。这个逻辑要落实到封装层比如我前面写的ConnectionManager::destroyConnection里先调用析构再归还内存池两条缺一不可。如果你不想每次都手工调也可以用RAII包装或者给对象实现统一的reset接口。6.4 池容量配置的艺术初始容量和最大容量怎么配这里没有万能答案但有两个实用经验。第一仔细观察业务的高峰水位。我一般是先裸跑一段malloc版本打点统计每秒活跃对象数画出曲线后取峰值加20%余量作为池容量。第二给池设置扩容策略而不是一次分配到位。比如初始分配1MB用完后再追加新的1MB块组这样内存占用是渐进增长的不会因为一次误估就吃下过多内存。还是那句话内存池配置是调优出来的不是拍脑袋定的。跑一段时间的监控数据再回头看配置是否合理比任何经验都靠谱。7. 定长内存池的进阶玩法定长内存池的技术点本身不复杂难的是把它用到合适的场景中并且和系统的其他部分协同得恰到好处。一种值得尝试的扩展是“对象复用池”本质上就是定长内存池加上空闲对象的重置逻辑。从一个对象池中取出对象时不是返回脏数据而是自动调用构造函数或重置方法把对象还原成初始状态。这在游戏引擎里非常常见——子弹、特效、音效、AI代理等创建销毁极其频繁的对象都这么管理。另一种玩法是给对象池加“年龄”或“代际”标记用来处理悬空指针问题。每次分配时在对象头部写入一个递增的世代号业务层保存的是“池指针世代号”的复合令牌。释放时比较世代号不匹配就说明这个对象已经失效了从而避免二次释放或者访问已释放内存。这个思路虽然增加了一点内存开销但在大型网络服务中非常实用。往更深处说定长内存池是实现高性能无锁数据结构的地基。比如无锁队列、无锁哈希表里节点内存的分配释放频繁且要求低延迟。用定长池管理节点再配合无锁栈做回收整个结构才能做到真正意义的无锁。我自己现在的选择倾向是默认情况下优先使用经过充分测试的通用内存池方案比如glibc的malloc替代品tcmalloc/jemalloc开箱即用效果已经很好。只有在明确识别出热点、瓶颈、以及对内存布局有特殊要求的场景下才会手写轻量级定长池。这个判断标准是几次无谓的造轮子经历换来的。最后说一个我反复踩坑之后沉淀下来的习惯写内存池代码时务必从第一天就加上统计信息和调试断言。这里的“统计”不光是分配了多少块还要记录历史上同时分配的峰值块数、空闲链表长度波动的直方图数据。有了这些数据你才能在线上环境从容地回答“池子缺不缺块、分配够不够快、浪费了多少内存”这些问题。定长内存池这道开胃小菜吃到嘴里不难但要嚼出味道、吸收营养还是得沉下心来把每一层细节都过一遍。希望这篇能帮你避开那些我已经踩平了的坑。