内存池仿Nginx C++实现
本篇不是逐行剖析 Nginx 源码的学习笔记——网上这类文章已经很多。这里记录的是我读懂源码之后,对应的 C++ 实现思路。
实现之后,我把它接入了 C++17 的std::pmr::memory_resource,作为底层内存分配源,用来优化项目里的 HTTP 路由解析。(这部分本篇不讲。)
虽然不逐行解析 Nginx 内存池源码,但设计思想、以及 C++ 在 Coding 层面与 C 的差异,会在行文中对照说明。
核心内存管理概念
概念部分稍显枯燥,但要理解 Nginx 内存池的设计,这是绕不开的前置。核心是两个:Bump allocation与Arena allocator。
它们解决的是同一类问题——小对象频繁分配带来的开销。前提是这批对象生命周期一致,可以整体一次性释放。
Arena allocator
传统分配(如malloc)每次都向操作系统要内存,频繁申请会带来内存碎片和性能损耗。
Arena 分配则相反:提前向系统要一块足够大的内存(即 Arena),之后所有小对象都在这块"场地"内部划分,不再陷入内核。
Bump allocation
Bump allocation 译作"指针碰撞",是一种靠指针移动来分配内存的技术,通常配合 Arena 使用。
先拿到一块大内存,再用几个指针描述它的状态:start记录起始位置,end记录结束位置,last指向当前尚未分配的位置。需要分配时,不做任何复杂查找,直接把last指针向后**“推”(Bump)**一段,划出请求的大小并返回。
这种方式很适合堆上频繁产生的临时对象。比如 C++ 里的std::string,小字符串的频繁分配与释放可能会占据一部分 CPU 热点。
代价也很直接:无法单独释放某个对象。回收只能整体进行——析构内存后令last = start,达到重置效果。指针只能前向移动或整体重置,没有中间状态。
Nginx 内存池结构
动手写 C++ 版本之前,得先看懂 Nginx 自身的结构设计。它的内存池结构体内部嵌套了多条链表。
// typedef struct ngx_pool_s ngx_pool_t in <ngx_core.h>structngx_pool_s{ngx_pool_data_td;size_tmax;// 4095ngx_pool_t*current;ngx_chain_t*chain;ngx_pool_large_t*large;ngx_pool_cleanup_t*cleanup;ngx_log_t*log;};实际编写时,我删掉了chain和log两个字段:前者服务于 I/O buffer,后者是调试日志,都与内存池的核心机制无关。
structngx_pool_s{ngx_pool_data_td;size_tmax;// 4095ngx_pool_t*current;ngx_pool_large_t*large;ngx_pool_cleanup_t*cleanup;};large和cleanup留到后文,这里先把它们都当作普通链表看待,从ngx_pool_data_t d入手。
ngx_pool_data_t
Nginx 小内存的基本单位,我称之为chunk,而ngx_pool_data_t正是用来描述一个chunk的。
typedefstruct{u_char*last;u_char*end;ngx_pool_t*next;ngx_uint_tfailed;}ngx_pool_data_t;举个例子。假设堆上分配了一个chunk,大小为chunk_size,由一个ngx_pool_data_t d来描述:
----------------------|已分配|未用空间|----------------------^^d->last=----|||d->end=----------------|d->next=指向下一个chunk d.failed=失败次数(详见下文代码)这就是 Arena 与 Bump 的图解:通过 bumplast指针快速为小对象划出地址,end标记边界、用于越界检查。
这里有两个值得澄清的点。
第一,ngx_pool_data_t d在栈上还是堆上?需要用户手动申请吗?
不需要用户申请,交由内存池自己处理,放在堆上。
第二,ngx_pool_data_t d与chunk是分开存储的吗?
这是 Nginx 设计的一个理解关键:结构体本身内嵌进chunk。原本的chunk_size,会在最前面取出sizeof(ngx_pool_data_t)的空间存放它自己。也就是说,前sizeof(ngx_pool_data_t)字节是描述信息本身,后面chunk_size - sizeof(ngx_pool_data_t)才是真正能分给对象的可用空间(暂不考虑内存对齐)。
-------------------------||^----------------------------------------|last|end|next|failed|已分配|未用空间|----------------------------------------|^---------------------------------|内嵌式结构的好处是省了一次malloc,代价则是占用了一部分chunk的可用内存。
以上讨论适用于第二块及之后的chunk。首块是特殊的,下一节单独说明。
ngx_pool_t
前面的结构代码已经表明,ngx_pool_t本身就包含一个ngx_pool_data_t。沿用ngx_pool_data_t的内嵌思路,Nginx 把首块chunk直接嵌进了内存池本体。
// first chunk-----------------------||d.last-------------------------------------|d|max|current|...|已分配|未用空间|-------------------------------------|d.end------------------------------|所以首块与其余 chunk 的区别就在于内嵌内容不同:首块内嵌的是内存池本体ngx_pool_t,第二块及之后内嵌的则是更轻量的ngx_pool_data_t。
更准确地说,首块和其余每个 chunk(连同各自内嵌的结构体)的整体size是相同的,但真正可用于分配的chunk_size不同,因为内嵌结构体的开销不一样:
- 首块:
chunk_size = size - sizeof(ngx_pool_t); - 其余:
chunk_size = size - sizeof(ngx_pool_data_t);
理清了内嵌关系,再回头看ngx_pool_t的各个字段:
typedefstructngx_pool_s{ngx_pool_data_td;size_tmax;// 4095ngx_pool_t*current;ngx_pool_large_t*large;ngx_pool_cleanup_t*cleanup;}ngx_pool_t;d是内存池本体内嵌的那个ngx_pool_data_t,它内部的next指针把后续所有 chunk 串成一条单链表——这是整个池子的骨架。
max表示每个 chunk(含内嵌结构体)的size,4095是单个 chunk 的上限。这个值同时也是小内存与大内存分配的分水岭:超过max的请求会走单独的大内存路径。
current指向当前"有效"的 chunk——所谓有效,是指它内部还有足够空间分配对象。它需要配合ngx_pool_data_t里的failed一起理解:当某个 chunk 的failed累计超过阈值,说明它已经反复装不下新请求了,current便跳过它指向下一块;若后续没有可用块,就触发新 chunk 的分配。这样做的意义在于,分配时不必每次都从头遍历那些大概率已经填满的旧块。
large用于大内存分配,cleanup用于资源清理,二者都是后文的主题。
光看描述很懵,看代码会对这些概念有更清晰的认识。
C++ 实现
我对Nginx源码进行了C++的一种重写, 删减了一部分, 但架构几乎一样。
如果你读懂下面的代码, 那么读Nginx内存池源码自然水到渠成; 反过来,如果你读过源码且有一定C++基础, 这就是一份项目上能用的C++翻版Nginx内存池
头文件
先展示代码, 然后下文挑重点说。 其余靠注释自行理解, 将下文代码喂给Claude code是一个好的方式。
classPool:publicruntime::base::NonCopyable{public:inlinestaticconstexprstd::size_t kDefaultChunkSize=1<<12;inlinestaticconstexprstd::size_t kMaxSmallAlloc=kDefaultChunkSize-1;inlinestaticconstexprstd::size_t kFailedThreshold=1<<2;inlinestaticconstexprstd::size_t kMinChunkSize=1<<7;structDeleter{voidoperator()(Pool*p)constnoexcept;};usingPtr=std::unique_ptr<Pool,Deleter>;// Pool must be placement-new'ed at the beginning of its own arena memory,// so stack allocation and direct new are intentionally disallowed.staticPtrCreate(std::size_t chunk_size=kDefaultChunkSize);// size <= max_ uses the bump arena fast path.// Larger allocations bypass the arena and use the large-allocation path.void*Allocate(std::size_t size);void*AllocateAligned(std::size_t size,std::size_t align);void*AllocateUnaligned(std::size_t size);void*Callocate(std::size_t size);// Only valid for large allocations.// Small allocations are reclaimed by Reset() or Pool destruction.voidFree(void*p)noexcept;// Does not execute cleanup handlers.// Releases large allocations and rewinds all chunk bump pointers.voidReset()noexcept;// handler(data) is executed in LIFO order during Pool destruction.// Returned data memory is allocated from the arena itself.void*RegisterCleanup(void(*handler)(void*),std::size_t data_size);std::size_tChunkCount()constnoexcept;std::size_tLargeCount()constnoexcept;std::size_tByteUsed()constnoexcept;private:structChunkHeader{std::byte*last;std::byte*end;ChunkHeader*next;std::uint32_tfailed;};structLargeNode{void*alloc;LargeNode*next;};structCleanupNode{void(*handler)(void*);void*data;CleanupNode*next;};explicitPool(std::size_t chunk_size)noexcept;~Pool()=default;voidDestroyArena()noexcept;void*AllocateSmall(std::size_t size,std::size_t alignment);void*AllocateLarge(std::size_t size);ChunkHeader*AllocateChunk();// reinterpret_cast<ChunkHeader*>(this) == &d_ChunkHeader d_;std::size_t max_;ChunkHeader*current_;LargeNode*large_;CleanupNode*cleanup_;};下面挑四个真正影响设计的点说明,其余靠注释自解释。
一、Create工厂 + placement-new:Pool 住在自己的 arena 里
这是整个类最反直觉、也最关键的设计。Pool的构造函数是private的,唯一入口是静态的Create。原因在于:Pool对象本身并不独立存在于某处,它就坐落在它所管理的那块 arena 内存的开头。
理解的要点是 C++ 申请原始字节与构造对象可视为两步。::operator new对应 C 中的malloc;placement-new构造对象。
对照 Nginx,首块 chunk 内嵌的是ngx_pool_t本体——C++ 版要复现这一点,就必须先::operator new出整块 arena,再用 placement-new 把Pool构造在这块内存的起始地址上。
正因如此,栈分配和普通new都被刻意禁止:如果Pool被分配在别处,它的this就不再是 arena 的起点,d_.last = this + sizeof(Pool)这套地址推算会整个失效。
二、Ptr与自定义Deleter:析构路径不能交给默认行为
为什么要定义删除器Deleter, 不是直接delete?
因为Pool是 placement-new 出来的,它的销毁就不能走delete——delete会同时调析构和::operator delete,但 placement-new 的对象内存不归它管。所以这里用std::unique_ptr<Pool, Deleter>包装,Deleter里手动编排了正确的三步:先DestroyArena()清理资源与后续 chunk,再显式调~Pool(),最后才::operator delete释放首块 arena。
这套逻辑钉进Deleter,使用者只需持有一个Ptr,RAII 自动兜底,无需内存管理。
三、ChunkHeader取代ngx_pool_data_t,并复用this == &d_
d_是ChunkHeader类型,且它是类的第一个数据成员,因此reinterpret_cast<ChunkHeader*>(this) == &d_成立。这让首块在遍历时可以和其余 chunk 一视同仁地当作ChunkHeader处理,省去为首块单独写一套逻辑。failed字段从 Nginx 的ngx_uint_t收窄成了std::uint32_t——计数器不需要 64 位,顺便压一点结构体体积。
四、三类节点分离:small / large / cleanup 各走各的链
LargeNode和CleanupNode被拆成独立的小结构体,分别串成两条链表,与 bump arena 的主链彻底解耦。这对应 Nginx 里large与cleanup各自成链的设计:大内存被单独Free,清理回调需要按 后进先出 LIFO 触发。
侵入式链表结构的一个特征是所有权平行分离。LargeNode和CleanupNode二者的生命周期语义都和"只进不退"的 bump 主链不同,混在一起会互相掣肘。
源文件
源文件里Create/Deleter/ 构造函数对应的就是头文件讲过的"placement-new 三步走", 具体逻辑自行阅读。
统计函数(ChunkCount/LargeCount/ByteUsed)和Reset都是直白的链表遍历与计数操作,看代码即可。
真正值得展开的是两条分配路径, 小对象走快路径分配, 读文件(大内存)走大内存路径单独malloc
AllocateSmall:bump fast-path 的核心
void*Pool::AllocateSmall(std::size_t size,std::size_t align){for(ChunkHeader*c=current_;/* void */;c=c->next){std::byte*aligned=AlignPtr(c->last,align);if(aligned<=c->end&&static_cast<std::size_t>(c->end-aligned)>=size){c->last=aligned+size;returnaligned;}if(c->next==nullptr)break;}// No existing chunk has enough space. Allocate a new chunk.ChunkHeader*fresh=AllocateChunk();std::byte*aligned=AlignPtr(fresh->last,align);void*result=aligned;fresh->last=aligned+size;// nginx-style heuristic:// increment failed counters for skipped chunks and// gradually advance current_ toward newer chunks.ChunkHeader*walk=current_;for(;walk->next!=nullptr;walk=walk->next){if(walk->failed++>=kFailedThreshold){current_=walk->next;}}walk->next=fresh;returnresult;}快路径就是前半段的循环:从current_出发,对每个 chunk 先把last按align对齐,再做一次边界检查——对齐后的地址不越过end、且剩余空间够size,就把last向后推并返回。整个过程没有查找、没有空闲链表,这正是 bump allocation 快的根源。
慢路径在所有现有 chunk 都装不下时触发:分配一块新 chunk,从它身上划出内存。
有意为之的实现选择,和 Nginx 原版略有不同。Nginx 是在分配前遍历的过程中递增failed;我是在新 chunk 分配完成后,单独走一遍walk循环来递增沿途 chunk 的failed,并在超过kFailedThreshold时把current_往后挪。语义上效果一致——某个 chunk 反复装不下,就挪动current_到有效的位置,后续分配从更新的块起步——只是我把"计数"与"快路径判断"拆开了,快路径循环保持纯粹,只管分配。
AllocateLarge:越过 arena 的大内存路径
void*Pool::AllocateLarge(std::size_t size){void*alloc=::operatornew(size);std::size_t probe=0;for(LargeNode*l=large_;l!=nullptr;l=l->next){if(l->alloc==nullptr){l->alloc=alloc;returnalloc;}if(++probe>=kLargeSlotSearch)break;}auto*node=static_cast<LargeNode*>(AllocateSmall(sizeof(LargeNode),alignof(LargeNode)));node->alloc=alloc;node->next=large_;large_=node;returnalloc;}超过max_的请求直接::operator new,绕过 bump arena——arena 是为小对象的密集分配设计的,大块内存塞进去会浪费可用空间,也破坏整体一次性释放的前提(大内存需要能被Free单独回收)。
两个细节值得一提。其一,新分配的指针不是无脑挂链:先探测链表前kLargeSlotSearch个节点,如果有被Free置空(alloc == nullptr)的槽位就直接复用,省一次LargeNode分配。
其二,LargeNode这个节点本身只有十几字节,让它也从 bump arena 里划出来——管理结构借住在它所管理对象的对立路径上,“能省一次分配就省一次”。
至于Free,它只对大内存有效:遍历large_链找到匹配指针,::operator delete后把槽位置空(留给上面的复用逻辑)。小对象不支持单独释放——这是 bump allocation 的固有代价,前文已经说过。
小结
到这里,一个翻版 Nginx 内存池的 C++ 实现就完整了。
小对象只进不退、批量重置;大对象单独管理、单独释放。
如上所说, 我删减了Nginx 内存池的一部分, 另外的差异是语言层面的设计思路。
工厂模式设计, 全堆分配,unique_ptr+ 自定义Deleter接管销毁路径, RAII封装 无需手动管理内存、std::byte与显式内存对齐。
这种内存池适用于游戏引擎和编译器生成语法树, 这些我只停留在描述上。
但我可以肯定, 它在HTTP路由解析和网关路由协议改写, 这是非常高效的。 这也是我最初学习并优化它的原因。
另外, 把它接入 C++17 的std::pmr::memory_resource——它能作为标准的memory_resource暴露出去,std::pmr::string、std::pmr::vector这些容器就能直接以它为分配源。 C++ 也提供Nginx风格的分配源, 感兴趣自行了解吧。
参考附录 && 版权声明
Nginx 源码 经典中经典 必看, 代码简洁优雅。
Apache 源码: Nginx 作者早期参考的经典, 代码过长自行阅读。
我的实现 头文件 觉得不错的star一下呗👋
我的实现 源文件
Arena 和 Bump
C语言 Arena起源论文- By D R.H
Nginx内存池源码
Apache内存池源码
