当前位置: 首页 > news >正文

内存池仿Nginx C++实现

本篇不是逐行剖析 Nginx 源码的学习笔记——网上这类文章已经很多。这里记录的是我读懂源码之后,对应的 C++ 实现思路。

实现之后,我把它接入了 C++17 的std::pmr::memory_resource,作为底层内存分配源,用来优化项目里的 HTTP 路由解析。(这部分本篇不讲。)

虽然不逐行解析 Nginx 内存池源码,但设计思想、以及 C++ 在 Coding 层面与 C 的差异,会在行文中对照说明。

核心内存管理概念

概念部分稍显枯燥,但要理解 Nginx 内存池的设计,这是绕不开的前置。核心是两个:Bump allocationArena 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;};

实际编写时,我删掉了chainlog两个字段:前者服务于 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;};

largecleanup留到后文,这里先把它们都当作普通链表看待,从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 dchunk是分开存储的吗?

这是 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(含内嵌结构体)的size4095是单个 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 各走各的链

LargeNodeCleanupNode被拆成独立的小结构体,分别串成两条链表,与 bump arena 的主链彻底解耦。这对应 Nginx 里largecleanup各自成链的设计:大内存被单独Free,清理回调需要按 后进先出 LIFO 触发。
侵入式链表结构的一个特征是所有权平行分离。LargeNodeCleanupNode二者的生命周期语义都和"只进不退"的 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 先把lastalign对齐,再做一次边界检查——对齐后的地址不越过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::stringstd::pmr::vector这些容器就能直接以它为分配源。 C++ 也提供Nginx风格的分配源, 感兴趣自行了解吧。

参考附录 && 版权声明

Nginx 源码 经典中经典 必看, 代码简洁优雅。
Apache 源码: Nginx 作者早期参考的经典, 代码过长自行阅读。
我的实现 头文件 觉得不错的star一下呗👋
我的实现 源文件

Arena 和 Bump
C语言 Arena起源论文- By D R.H
Nginx内存池源码
Apache内存池源码

http://www.jsqmd.com/news/882289/

相关文章:

  • 如何3分钟配置智慧树自动刷课插件:终极高效学习解决方案
  • 终极NCM文件解密教程:一键解锁网易云音乐加密格式
  • 别再只盯着DAVIS数据集了!手把手教你用Python复现Space-Time Memory Networks(附代码)
  • 十二周学习报告
  • 2026哪个品牌的排插好?安全实用与设计感兼具之选 - 品牌排行榜
  • WebFlux + R2DBC 场景下的分库分表预研:从架构选型到落地风险
  • Windows 10/11 下保姆级教程:VMD 1.9.4 和 NAMD 3.0 分子模拟环境一键配置(含注册避坑)
  • 工业异常检测实战:从多模态数据集构建到AI模型评估全解析
  • 引力波透镜探测:参数偏移与似然比检验的统计框架与应用
  • AI 系统分层治理:从用户无感知降级到多能力协同的架构演进
  • [408] [数据结构] 链表-代码基础
  • C# 集合详解:ArrayList 与 List<T>的核心用法与对比
  • 线性系统理论学懵了?手把手带你推导能控性格拉姆矩阵判据(附详细证明步骤)
  • 数据驱动负载减载:应对电力系统网络攻击的智能稳定控制
  • 【Verilog代码规范引起的国产安路编译器不能识别寄存器】
  • common lisp 张量,矩阵计算库介绍
  • 苏州相城区宠物基地口碑推荐榜单一览 - 品牌排行榜
  • 保姆级教程:在Ubuntu20.04上为ROS2机器人项目配置CUDA11.3与TensorRT推理环境
  • SubCube稀疏注意力架构的优势是什么
  • PHP无参RCE
  • 医疗物联网异常检测:八种机器学习算法实战对比与选型指南
  • Armv9 SME指令集:矩阵运算加速原理与优化实践
  • 量子生成模型:原理、优势与应用场景解析
  • 终极指南:3种简单方法快速重置JetBrains IDE试用期
  • 大麦网抢票神器终极指南:告别黄牛票的Python自动化解决方案
  • ARM ETE协议异常处理与指令追踪技术解析
  • 3分钟快速修复:洛雪音乐六音音源终极解决方案
  • 增强采样与力匹配结合:高效构建高精度粗粒化分子动力学模型
  • 3分钟快速修复洛雪音乐播放问题:六音音源完整指南
  • 音频输入系统——第二周