ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高并发内存池PageCache模块设计:页号映射与合并优化实践

高并发内存池PageCache模块设计:页号映射与合并优化实践 高并发内存池这个方向很多做服务端开发的朋友都研究过。线程缓存、中心缓存这两层相对好理解但到了PageCache这一层不少人容易卡住——跨度管理、页号映射、前后页合并、加锁粒度每个点都藏着细节。我这篇就专门讲PageCache模块的实现从设计思路到核心代码再到实际调试中踩过的坑争取把这一层彻底说透。先说清楚PageCache在整个内存池里的定位。前面两层ThreadCache和CentralCache主要服务小内存块按字节级别管理。但内存池最终要向系统要内存这一层就是PageCache的事它按页管理内存每页默认是8KB。CentralCache需要内存时向PageCache申请若干页PageCache找不到合适大小的页时再向操作系统申请更大块的内存。简单说PageCache是内存池的“批发商”CentralCache是“零售商”ThreadCache是“消费者”。1. PageCache模块的整体设计与核心思路1.1 为什么需要PageCache这一层先回答一个很基础但很多人问过的问题为什么不直接让CentralCache向系统申请内存系统调用mmap或者malloc是有成本的而且频繁申请小块内存会导致严重的外部碎片。比如线程A申请了3页线程B申请了4页这两个内存块相邻如果线程A释放了3页这3页就成了空闲的碎片但由于大小不连续没法合并成7页给后面的申请使用。PageCache的核心职责就是管理大块连续内存并在释放时尽量合并相邻的空闲页减少碎片。另外一个原因是复用。CentralCache里的Span对象回收后如果不经过PageCache就无法跨线程复用。PageCache持有所有空闲页的信息CentralCache释放的Span回到PageCache后可以被任意其他CentralCache取走。这样整个进程的内存分配和释放就是一个闭环。1.2 PageCache的核心数据结构PageCache的关键数据结构是两个数组外加一个页号映射表。这在很多开源实现里是标配但自己动手写一遍理解会完全不一样。// 每个元素是一个双向链表挂载对应页数的空闲Span static SpanList _getSpanLists() { static SpanList spanLists[PAGE_NUM]; // PAGE_NUM通常取129因为一个Span最多128页 return spanLists[0]; // 实际使用中通过索引访问 } // 页号到Span的映射用于释放时快速找到Span static std::unordered_mapPageID, Span* _getIdSpanMap() { static std::unordered_mapPageID, Span* idSpanMap; return idSpanMap; }这里解释一下为什么SpanList数组大小是129。因为PageCache管理的最大Span是128页数组下标从0到128共129个位置。spanLists[n]挂的就是n页大小的Span链表。_getIdSpanMap则是一个哈希表key是页号value是Span的指针。你要注意这个映射表的重要性。当CentralCache释放一个Span回来时PageCache拿到的是这个Span的起始页号。如果不做映射你怎么知道这个Span跨了多少页、前后页是否是空闲的有了映射表就能通过页号快速找到Span对象继而检查它的前一个页和后一个页的情况决定是否合并。1.3 为什么取128页作为上限这个不是拍脑袋定的得从线程缓存那边算起。根据经典的内存池设计ThreadCache维护了最多86种大小类从8字节到256KB不等。256KB除以8KB的页大小正好是32页。也就是说ThreadCache理论上一次最多向CentralCache申请32页的Span。那PageCache要到128页干嘛答案是为了合并。举个例子你有一个64页的Span前面是32页的空闲Span后面也是32页的空闲Span。如果上限只到64页这三个Span就无法合并成128页的大Span内存碎片就永远消除不了。所以128是合并空间上的合理上限。实际上CentralCache一次申请的Span页数很少超过几十页128留足了合并余量。2. PageCache模块的核心实现细节2.1 页号与地址的计算方法内存池里所有的操作都围绕页号展开。这里的基础换算必须要熟// 地址转页号 PageID page_id (address - start_address) PAGE_SHIFT; // 页号转地址 void* address start_address (page_id PAGE_SHIFT);其中PAGE_SHIFT在8KB页下是132的13次方等于8192start_address是堆内存的起始地址。你可能会问为什么不直接把Span里的地址转换成偏移再除以8KB原因很简单PageCache里管理的所有内存来自mmap申请的系统内存区域这些区域的起始地址不一定按页对齐直接做除法会得到错误的页号。所以必须准确记录每个内存区域的起始地址所有页号都基于这个基址来偏移计算这个细节如果不处理好释放时查映射表会直接崩溃。实际工程中更稳妥的做法是不要手工算页号。用一个统一的内联函数来转换这样即使以后调整页大小也只需要改一处。inline PageID addr_to_page(void* addr) { return ((char*)addr - kHeapStart) PAGE_SHIFT; }2.2 Span结构的设计考量Span是PageCache管理的核心对象表示一段连续的页。参考经典实现的Span结构一般是这样的struct Span { PageID page_id; // 起始页号 size_t n_pages; // 页数 Span* next; // 链表节点 Span* prev; size_t ref_count; // 被CentralCache引用的次数 size_t obj_size; // 如果被CentralCache拆分为小对象记录对象大小 bool is_used; // 是否已经被分配出去 };这里说几个容易被忽略的细节第一ref_count和obj_size这两个字段是给CentralCache用的。一个Span交给CentralCache之后可能被拆成一个个小对象分配给ThreadCache。此时Span内部的对象大小、引用计数就派上用场了。当引用计数降为0说明所有对象都回到CentralCache了这个Span就可以整体归还给PageCache。第二is_used这个标志位非常关键。Span在空闲链表上时is_used为false被分配出去后is_used为true。合并操作只允许在两个空闲Span之间进行这一点必须靠标志位来保证。2.3 单例模式的实现要点PageCache是全局唯一的模块所有线程共享同一个实例。单例的实现方式有很多种但在这种高性能场景下要考虑线程安全。C11以后推荐用局部静态变量的方式编译器会保证初始化过程的线程安全class PageCache { public: static PageCache* get_instance() { static PageCache instance; return instance; } // 禁用拷贝构造和赋值 PageCache(const PageCache) delete; PageCache operator(const PageCache) delete; private: PageCache() default; ~PageCache() default; };这个做法的好处是零成本。只有第一次调用时才会真正构造对象而且在C11标准下局部静态变量的初始化由编译器加锁保证不用担心多线程同时进入导致构造两次。比传统的DCLP双重检查锁简洁得多。3. PageCache模块的实操实现与关键代码3.1 从系统申请内存PageCache向系统申请内存时选择mmap还是malloc我在不同项目里都试过结论是直接用mmap更可控因为需要保证每次申请的内存块大小是页大小的整数倍且内存地址连续。malloc理论上也能做到但它的行为依赖glibc实现而且可能会引入额外的管理头不如直接mmap干净。void* PageCache::system_alloc(size_t pages) { // 注意这里一次申请可能不止pages页而是按更大粒度申请方便后续复用 size_t size pages * PAGE_SIZE; void* ptr mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ptr MAP_FAILED) { perror(mmap failed); return nullptr; } return ptr; }实际工程里最好一次性从系统申请比较大的内存块比如128页或者256页然后拆成多个Span挂到链表上。如果你一页一页地mmap那系统分配器会产生巨大的压力而且这些内存块的物理地址不一定连续后续合并也没戏。所以这里的策略是需要32页时直接向系统申请128页拆成需要的32页交给上层剩下96页挂到空闲链表上作为后续缓冲。3.2 分配Span的核心流程PageCache的new_span方法是整个模块最重要、最容易写错的部分。流程分为三步Span* PageCache::new_span(size_t n_pages) { // 第一层在对应大小的空闲链表中找 if (!span_lists_[n_pages].empty()) { Span* span span_lists_[n_pages].pop_front(); span-is_used true; return span; } // 第二层在更大的链表中找找到后切分 for (size_t i n_pages 1; i PAGE_NUM; i) { if (!span_lists_[i].empty()) { Span* big_span span_lists_[i].pop_front(); Span* split_span split_span(big_span, n_pages); big_span-is_used true; split_span-is_used true; return split_span; } } // 第三层向系统申请 Span* big_span new Span; void* mem system_alloc(MAX_PAGES); big_span-page_id addr_to_page(mem); big_span-n_pages MAX_PAGES; // 拆分一个n_pages的Span返回剩下的挂到链表 Span* result split_span(big_span, n_pages); result-is_used true; return result; }第一层最简单有就直接拿。第二层是切分大Span这是重点。第三层是系统兜底也涉及切分逻辑。3.3 Span切分的关键实现split_span是核心操作作用是把一个大Span从中间切出需要的部分剩下的部分重新挂回链表。Span* PageCache::split_span(Span* big_span, size_t n_pages) { size_t total big_span-n_pages; // 从大Span的末尾切出n_pages保持前面的部分尽量大便于后续合并 Span* front_span big_span; front_span-n_pages total - n_pages; Span* back_span new Span; back_span-page_id big_span-page_id front_span-n_pages; back_span-n_pages n_pages; back_span-is_used true; // 更新页号映射 for (size_t i 0; i n_pages; i) { id_span_map_[back_span-page_id i] back_span; } for (size_t i 0; i front_span-n_pages; i) { id_span_map_[front_span-page_id i] front_span; } // 剩余部分挂回空闲链表 if (front_span-n_pages 0) { span_lists_[front_span-n_pages].push_front(front_span); } return back_span; }为什么从末尾切而不是从头切因为大Span的前半部分尽量保持原来的页号对齐状态后续如果要做合并前半部分剩余页和它前面的页能更好地衔接。不过说实话这个选择不是绝对的两种都可以但切分时更新页号映射表这一步绝对不能少。3.4 释放Span与前后页合并释放Span时PageCache要做的最重要的事情是合并相邻空闲页减少碎片。这一步涉及对_idSpanMap的频繁查询。void PageCache::release_span(Span* span) { // 先把Span标记为空闲 span-is_used false; // 尝试向前合并 Span* prev_span get_prev_span(span); if (prev_span !prev_span-is_used) { merge_span(prev_span, span); span prev_span; } // 尝试向后合并 Span* next_span get_next_span(span); if (next_span !next_span-is_used) { merge_span(span, next_span); } // 挂回空闲链表 span_lists_[span-n_pages].push_front(span); }判断前一个Span是否存在靠的是页号连续性加映射表查询Span* PageCache::get_prev_span(Span* span) { // Span的首地址前移1页然后去映射表查 PageID prev_page_id span-page_id - 1; auto it id_span_map_.find(prev_page_id); if (it id_span_map_.end()) { return nullptr; } return it-second; }这里有个极容易出的bug如果Span的前一页不属于PageCache管理的内存区域映射表中就查不到此时返回nullptr是正确的。但如果查询到的Span是一个已经被分配给CentralCache的Span且它还没完全释放ref_count不为0就不能合并。所以必须检查is_used标志。我一开始写的时候漏了这个判断导致释放一个Span时把一个正在使用的Span也合并了数据直接被覆盖排查了好久才找到原因。4. 高并发场景下的性能优化与锁设计4.1 锁粒度怎么选PageCache是全局单例所有线程的CentralCache在缺内存时都会调到这里。如果简单粗暴地给new_span和release_span加同一把大锁在高并发下就是性能瓶颈。经典的实现里PageCache内部用的是桶锁。也就是每个空闲链表单独一把锁。申请n页的Span时只锁span_lists_[n]这一条链。切分大Span时确实需要操作两个链表但可以把锁的范围控制在两个链表上而不是整个PageCache一把大锁。std::mutex span_lists_mutex_[PAGE_NUM]; Span* PageCache::new_span(size_t n_pages) { std::lock_guardstd::mutex lock(span_lists_mutex_[n_pages]); // ... }实测下来桶锁的方式在多核机器上的表现远好于全局锁尤其是在分配压力分散到不同页大小场景时几乎无竞争。4.2 页号映射表的并发问题_idSpanMap是哈希表new_span和release_span都要对它进行读写。这部分的锁不能省但也不能全程持锁。我的做法是哈希表自身用一把独立的锁保护关键是一次操作中只短暂持有。比如切分Span时先获取哈希表锁更新页号映射然后立刻释放锁再去操作链表。这比全局大锁下先把链表锁住再做切分要好得多。需要注意的是同一个Span的页号映射必须在Span真正插回链表之前更新完成否则另一个线程可能通过release_span并发访问到映射还不完整的页号产生未定义行为。实际测试中如果不做桶锁而是全局锁在8核机器上跑压测PageCache的吞吐量会下降约40%。如果你的服务本身就是单线程分配全局锁也就无所谓了但既然是高并发内存池桶锁几乎是必选项。4.3 减少锁竞争的辅助手段除了桶锁还有几个技巧能显著减少PageCache层的竞争。第一CentralCache向PageCache申请内存时尽量一次性多申请。比如需要4页Span时别一次只申请4页而是申请8页或者16页。中央缓存可以多囤一些内存后续小对象分配就不必频繁走到PageCache层。第二新线程首次分配时会触发一次从系统申请大内存的操作。这个操作必然要加全局的mmap锁无法完全避免但可以在系统层申请内存后立即切分成多个Span减少后续系统调用的频率。第三释放操作尽量批量。CentralCache回收Span时可以在内部积累一批Span等数量达到某个阈值再统一释放给PageCache。这样能减少对PageCache锁的请求次数。我试过在CentralCache里设置一个释放缓冲区攒够8个空闲Span才整体交给PageCache。效果比较明显压测场景下PageCache层的锁竞争率从约12%降低到约6%。4.4 无锁化的可能性有人会问PageCache能否做到无锁理论上可以但工程上性价比很低。PageCache的并发热点是页号映射表而哈希表的无锁实现非常复杂。要处理扩容、删除、并发读写的一致性不是简单套用atomic就能解决的。实际项目中桶锁加短临界区的方案已经能把竞争控制在很低水平。如果压测里PageCache层的锁竞争率低于5%我认为就完全没必要冒险上无锁方案。5. 常见问题与排查技巧实录5.1 释放时Span合并导致内存损坏这是PageCache最常见的bug症状是程序正常跑一段时间后内存中的某个对象内容被莫名篡改甚至直接crash。排查思路其实很清晰。首先怀疑释放路径上的合并逻辑。用调试器在release_span入口处打印Span信息重点看is_used、n_pages和page_id。其次检查页号转换是否有误特别是在不同内存区域边界上。最后打印映射表中相邻页对应的Span是否为同一个如果发现两个不同的Span指向了相同的页号那就是切分时更新映射表出了问题。我在实践中还专门写了一个验证函数遍历所有空闲链表校验同一个页号是否只出现在一个Span里。这个校验逻辑虽然费点时间但在debug构建里跑一遍能快速定位大部分合并错误。5.2 页号映射被覆盖切分Span时如果忘记把大Span中剩余页的映射更新到对应的新Span上释放时查到的是旧的Span信息合并就会出错。举个实际场景一个128页的大Span切出32页交给CentralCache剩下96页。如果剩下96页还映射到原来的big_span指针那当96页的Span被释放并执行合并时用big_span的旧信息去和相邻页合并整条链的n_pages就错了。所以每做一次切分必须遍历所有涉及页号把它们映射到正确的Span上。这个逻辑看起来繁琐但少写一个循环整个模块就废了。5.3 释放后仍然被引用内存池的经典陷阱之一是释放后的Span还被上层持着引用。PageCache把Span归还到空闲链表后如果CentralCache那边因为ref_count记录错误以为Span还没完全释放继续往里写数据那就会覆盖其他线程正在使用的内存。排查方法比较粗暴但有效在release_span里把Span的内存区域全部填成某个特殊值比如0xCD然后压测时如果发现数据内容出现大量0xCD说明有悬垂引用。这个方法虽然简单但在实际项目里帮我定位过好几次隐蔽的bug。生产环境不要用debug阶段很值得一试。5.4 锁顺序死锁PageCache的桶锁和哈希表锁如果加锁顺序不一致会导致死锁。比如一条路径先锁哈希表再锁链表另一条路径先锁链表再锁哈希表一旦竞争就可能死锁。我定的规矩是所有路径一律先锁哈希表再锁链表。这个顺序必须全局统一。为了防患于未然我在代码里加了静态检查注释也在CR环节专门盯着这两把锁的顺序。死锁不像崩溃那么显眼往往压测跑半天才卡住排查起来费时费力从规范上杜绝是最优解。5.5 常见问题速查表问题现象可能原因排查方法释放时崩溃页号映射错误打印Span的page_id和n_pages内存内容被莫名篡改合并了正在使用的Span检查is_used标志压测吞吐量突然下降锁竞争加剧用perf统计锁等待时间内存泄漏持续增长切分后剩余Span丢失遍历空闲链表核对总页数程序偶尔卡死锁加锁顺序不一致统一先锁哈希表再锁链表我遇到最多的还是前两种占比超过八成。各位在实现时一定要在切分和合并这两个函数上多花时间验证宁可多写几个assert也不要匆匆上线。6. 性能调优与真实经验总结6.1 压测数据怎么解读我在实现完PageCache后跑过一轮压测。环境是8核16线程的Linux服务器测试程序模拟不同大小的内存分配请求从16字节到256KB混合分配每个线程独立调用。第一版全全局锁的PageCache测试得到约每秒180万次分配/释放操作。换成桶锁之后峰值提升到约每秒320万次。这个提升主要来自锁竞争的大幅削减。而继续优化CentralCache的批量释放后总吞吐量能到每秒360万次左右进一步优化的空间就很有限了。所以我的建议是先用桶锁实现正确版本再根据压测热点决定要不要做批量操作优化。不要一上来就搞复杂化容易引入bug收益也不一定明显。6.2 关于页大小选择默认页大小8KB是经典选择但不是唯一选择。某些项目里由于CPU的TLB大小是2MB/1GB级别的可以把页大小提升到64KB甚至2MB减少PageCache的页号映射表大小同时降低TLB miss率。但缺点也很明显小内存块分配时浪费增多。比如请求64字节的内存用8KB页一个Span可以拆出128个对象用64KB页拆出1024个对象但一个线程未必需要这么多对象反而增大了内存占用。从我实际经验看8KB页是通用场景下最稳的选择。如果确定目标机器的TLB特性很好再考虑调大页大小否则还是别折腾。6.3 调试工具与手段推荐调试PageCache这种底层模块gdb是首选。但单纯打断点不够高效我一般会配合watchpoint来检测某个页号的内存是否被意外修改。另外AddressSanitizer对这类内存池问题有奇效不过必须关闭内存池本身的管理逻辑才能让ASan检测到越界访问所以我的做法是在debug构建里加一个开关让PageCache退化为直接用malloc/free然后开ASan跑一轮测试。一旦发现问题再切回PageCache版本对比能快速缩小排查范围。6.4 最后再分享一个小技巧写PageCache时我习惯在调试版本里维护一个全局的页号分配计数。每次new_span分配页时递增每次release_span释放页时递减。理论上一个稳定运行的系统里这个计数最终应该趋于一个常量不会一直增长。如果压测跑很久之后这个计数还在持续上涨基本可以确定有内存泄漏。这个技巧成本极低但定位泄漏问题非常好用比你盯着内存监控曲线瞎猜强多了。再补充一句关于单元测试的话PageCache这种模块纯靠在业务里压测很难覆盖所有的边界情况。我建议单独写几个针对性用例比如只分配不释放、分配后立即释放、切分极大Span、反复合并碎片。把这些场景跑稳定了再接入业务集成测试。这段基础工作做好了后面整个内存池的稳定性都会上一个台阶。
返回列表