资讯动态

高并发内存池PageCache核心实现:span切分、合并与映射机制详解

发布时间:2026/10/3 4:40:06 来源:尧图企业网站定制
写完ThreadCache和CentralCache之后很多人会对内存池产生一个错觉好像核心逻辑已经完事了PageCache无非就是“向系统申请一大块内存再切一切”。真动手写你才会发现高并发内存池里最容易出隐蔽bug、也最考验对内存生命周期理解的就是这个模块。PageCache是整套体系的地基它管的是“页”不是字节。这一篇就把PageCache模块实现从头到尾拆开讲清楚包括span的结构设计、切分与合并机制、哈希映射方案以及我在实际调试中踩过的坑。如果你正在做高并发内存池项目或者准备面试时被问到tcmalloc三层结构里的PageCache这篇文章应该能帮你把这块短板补上。1. PageCache在高并发内存池中的核心职责1.1 为什么三层架构里少不了一个PageCache先回顾一下整个池子的运作路径。ThreadCache是每个线程私有的线程在这里按size class直接拿内存块速度快但没有全局视野CentralCache是全局的ThreadCache没命中时到这里按span批量取内存内部用桶锁保证并发安全那CentralCache的内存又是从哪里来的如果每个CentralCache桶都直接向系统malloc那本质上还是在依赖操作系统的分配器根本没解决“频繁系统调用”的问题。PageCache就是专门解决这个问题的第三层。它的作用很清晰向操作系统申请大块连续内存以“页”为粒度管理这些内存然后按CentralCache的需求把大块span切分成小块span。同时它还负责反向的回收工作——CentralCache里不再使用的span最终会回到这里PageCache把这些零散span按照物理页号的连续性合并成大span以备后续更大的分配请求复用。简单来说CentralCache管的是“8字节对齐的对象”PageCache管的是“4KB一页的物理连续内存”。前两层都在做缓存PageCache才是真正和操作系统打交道的地方。1.2 别把PageCache写成CentralCache的附属品我在看很多教学项目的实现时发现有人把PageCache理解成“CentralCache的一个大桶”所有span乱糟糟地挂在一个链表上需要时就从头部随便拿一个。这么做在单线程测试里勉强能跑但并发一上来就原形毕露每次分配都要遍历链表找合适的span时间复杂度和锁竞争都失控了。真正的设计必须做到两点按页数分桶管理不同页数的span挂在不同的链表中查找时直接定位到对应桶不需要遍历物理页号可索引任何一个span都能按照它的起始页号在全局映射中找到它这是合并时不回溯遍历的基石也就是说PageCache不仅是个“内存仓库”还得是个“内存台账”。每块内存从哪里来、现在是谁在用、能不能合并回大块都要记录清楚。只有这样CentralCache向它要span时能高效分配CentralCache把span还给它是能正确合并。2. PageCache的底层数据结构设计2.1 Span结构体定义与关键字段解析先给出我在项目里实际使用的span定义后面所有逻辑都围绕它展开// 页大小固定为4KB1 PAGE_SHIFT static constexpr size_t PAGE_SHIFT 12; static constexpr size_t PAGE_SIZE 1 PAGE_SHIFT; // 管理连续页的跨度结构 struct Span { size_t page_id; // 起始页号等于 (地址 PAGE_SHIFT) size_t n_pages; // 本span占用的连续页数 Span* next; // 双向链表前驱 Span* prev; // 双向链表后继 bool is_used; // 是否已被CentralCache使用 size_t obj_size; // 如果被切成小对象记录对象大小否则为0 void* free_list; // 被切分后挂载未分配对象的自由链表头 };这里面的核心是page_id和n_pages。page_id是把内存地址右移12位得到的它直接标识了这块内存在物理地址空间中的位置。为什么要存这个字段而不是直接存指针因为合并的时候我们要通过“前一个span的起始页 它的页数”来判断两块内存是否物理相邻用指针做差值计算也能得到页数但很容易在指针运算时搞出未定义行为用无符号整数页号更安全、更直观。is_used标记了这块span是否已经分配给了CentralCache。这个标记在合并时至关重要释放回来的span如果前后邻居正被使用那就不能合并只有两边都空闲且物理相邻才能合并成一个更大的span。2.2 128个桶的双向链表组织PageCache内部的核心容器是一组span双向链表按页数分桶class PageCache { public: // 单例 static PageCache* GetInstance() { static PageCache instance; return instance; } // 从PageCache获取k页的span Span* NewSpan(size_t k); // 将span释放回PageCache尝试合并 void ReleaseSpanToPageCache(Span* span); private: // 双向链表下标是页数PageCache最多管理128页以内的span std::arraySpanList, 128 span_lists_; // 页号到span的映射用于合并时快速查找 std::unordered_mapsize_t, Span* page_map_; std::mutex mutex_; // 全局锁 // 向系统申请n页内存 void* SystemAlloc(size_t n_pages); };我这里用的std::arraySpanList, 128下标直接对应该span的页数。span_lists_[0]通常不用span_lists_[1]挂的是1页的spanspan_lists_[127]挂的是127页以内的span。那超过128页怎么办在设计上当需要一个大span而现有桶里没有时PageCache会一次性向系统申请128页的大内存然后不断对半切分、循环递补最后把满足需求的span返回给上层。这种策略让“向上申请”的频次非常低同时切分出来的剩余span会挂到对应页数的桶里后续小请求可以直接命中不用再次碰系统调用。为什么是128而不是其他数值这是参考tcmalloc的做法同时结合了实际申请代价的折中。一次申请128页512KB对绝大多数服务来说是比较温和的不会让系统内存瞬间膨胀太多而小于128页的span请求在大多数场景下里都能通过切分策略覆盖到。这个值不需要绝对精确但选得太小会导致频繁向系统申请选得太大比如1024会让小内存场景下的碎片和浪费变明显。2.3 为什么PageCache必须做成全局单例CentralCache和ThreadCache的数量和生命周期是明确的ThreadCache每线程一个CentralCache全局一份但CentralCache内部有多个桶每个桶有自己的锁。如果PageCache不是单例那高位内存的归属就乱了线程A可能从PageCache实例1拿内存线程B可能从实例2拿内存内存的申请和释放无法统一管理合并逻辑也彻底失去意义。正确做法是让PageCache作为全局唯一实例内部使用一把独立的互斥锁。CentralCache的桶锁和PageCache的锁是两层锁不是一把锁所以并发访问时不会出现把全局锁和桶锁混为一谈的死锁问题。这里可以稍微展开一下锁的交互线程在CentralCache的某个桶中没找到可用span调用PageCache::NewSpan前CentralCache已经持有桶锁NewSpan内部会获取PageCache的全局锁如果此时别的线程正在切分大span当前线程会等待拿到span后PageCache的全局锁释放CentralCache在自己的桶锁内继续把span切成对象这种两层锁的关系看上去有点嵌套但实际上不同锁保护不同的资源只要保证获取顺序一致先桶锁后全局锁或者全局锁内不回头去抢桶锁就不会死锁。3. 最核心的机制span切分与向上申请3.1 NewSpan的完整流程拆解PageCache向CentralCache提供内存的核心方法是NewSpan(size_t k)。很多人一上来就写“先找k页的桶找不到就申请128页”但真正实现的时候要处理的细节远不止这些。我按照实际调用路径逐步拆解第一步先检查span_lists_[k]是不是为空。如果非空直接从该桶头部取一个span标记is_used true从链表中摘除返回给CentralCache。这一步很简单但要注意取出后必须尽快从双向链表中摘除否则后续合并逻辑可能会把自己合并了。第二步如果k页的桶为空说明当前PageCache里没有正好等于k页的span。那就需要从更大的桶里“借用”。具体做法是从k1开始向上遍历span_lists_找到第一个非空的桶假设它的头span页数是n_pages。这里有一个关键策略既然拿出来的span页数比需求的页数多就一定要切分。切分逻辑是Span* PageCache::NewSpan(size_t k) { // 1. 先看k页桶里有没有 if (!span_lists_[k].Empty()) { Span* span span_lists_[k].PopFront(); span-is_used true; return span; } // 2. 从更大的桶里找到一个非空span页数记为n for (size_t i k 1; i 128; i) { if (span_lists_[i].Empty()) { continue; } Span* big_span span_lists_[i].PopFront(); // 切分big_span拆成 k页 (n - k)页 两部分 size_t remain big_span-n_pages - k; if (remain 0) { Span* remain_span new Span(); remain_span-page_id big_span-page_id k; remain_span-n_pages remain; remain_span-is_used false; span_lists_[remain].PushFront(remain_span); // 新span也要登记映射方便后续回收时查找 page_map_[remain_span-page_id] remain_span; } big_span-n_pages k; big_span-is_used true; page_map_[big_span-page_id] big_span; return big_span; } // 3. 所有桶都找不到向系统申请128页 void* memory SystemAlloc(128); Span* new_span new Span(); new_span-page_id reinterpret_castsize_t(memory) PAGE_SHIFT; new_span-n_pages 128; new_span-is_used false; page_map_[new_span-page_id] new_span; span_lists_[128].PushFront(new_span); // 递归调用自己让上面的切分逻辑处理 return NewSpan(k); }注意这里的切分方向剩余部分remain_span是从big_span的后半部分切出来的big_span保持低地址部分返回给CentralCache。这样做的原因是保证返回给CentralCache的span从低地址开始方便CentralCache内部把span拆成对象链表后对象的地址递增关系保持确定。虽然页号本身是物理地址不涉及缓存性能和局部性问题但从低地址切分在调试时更容易追踪。3.2 向上申请128页背后的权衡当所有桶都没有空闲span时才允许向系统申请新内存。有人会问为什么不直接申请需要的k页而要一次性申请128页这正是PageCache存在的意义把“向系统申请内存”这种昂贵的操作做成低频事件。一次系统调用如果是malloc(128页)在内核态的开销是固定的大头把一次申请的代价摊到后续几十上百次内部分配上均摊成本就非常低了。此外一次性申请大块内存后剩余部分全部暂存在PageCache的桶里后续同类请求都能直接命中响应时间非常稳定。这也带来一个副作用内存不会立刻还给操作系统。PageCache里空闲的span只要没被合并成大块、再进一步归还就会一直待在进程里。在设计时如果你做的是长期运行的服务型项目建议额外加一个“内存水位”统计当空闲span总量超过某个阈值时周期性地把大块span释放给操作系统避免常驻内存过高。面试时主动提到这一点往往能加分因为绝大多数项目都不会想到这层。3.3 切分过程必须同步更新页号映射切分代码里最容易被遗忘的是page_map_的更新。从一个128页的span切出剩余部分时新产生的remain_span虽然是同一块物理内存的后半段但它已经变成了一个新的管理单元必须用它的起始页号登记到映射表里。如果不登记后续CentralCache把remain_span释放回来时ReleaseSpanToPageCache通过page_map_根本找不到它就无法把它拼回原来的大span。这个问题很难通过单元测试暴露出来因为单个线程下只要你串行使用不合并也看不出毛病并发一上来内存泄漏和碎片化会迅速累积最终表现为进程内存只涨不降。建议在每次切分、合并后都写一个调试辅助函数把当前page_map_里所有span的(page_id, n_pages, is_used)打印出来肉眼检查连续性。实测这个土办法比什么Idea工具都好用。4. 回收与合并PageCache的“内存整理术”4.1 为什么合并时前后邻居必须同时考虑CentralCache在某个span内的对象全部归还后会把整个span交还给PageCache。PageCache拿到这个span后的第一件事不是直接挂到桶里而是先尝试和前后物理相邻的空闲span合并。这一步如果不做PageCache里就会充满各种碎块有3页的、5页的、17页的杂七杂八地挂在不同桶里。等将来有个请求需要64页的大span时就算碎片加起来有几百页也无法满足一次分配。合并且只考虑一个方向是远远不够的。假设要释放的span是S页号范围是[5, 9)前面邻居住着页号[0, 5)的空闲span后面邻居住着页号[9, 13)的空闲span。只往前合并时得到[0, 9)此时该span和[9, 13)仍然是物理相邻的可以继续合并成[0, 13)。反过来说先往后合并再回头看前一个邻居同样还能继续合。所以正确做法是合并动作要循环执行直到前后都没有可合并的空闲邻居为止。4.2 释放span合并的完整逻辑void PageCache::ReleaseSpanToPageCache(Span* span) { span-is_used false; // 1. 向前合并看span前一个页号对应的span if (span-page_id 0) { Span* prev_span page_map_[span-page_id - 1]; if (prev_span !prev_span-is_used prev_span-page_id prev_span-n_pages span-page_id) { // prev_span的结束位置正好是span的起点说明物理相邻 // 从链表摘除prev_span span_lists_[prev_span-n_pages].Erase(prev_span); page_map_.erase(prev_span-page_id); // 合并到prev_span prev_span-n_pages span-n_pages; delete span; span prev_span; } } // 2. 向后合并看span后一个页号对应的span { Span* next_span page_map_[span-page_id span-n_pages]; if (next_span !next_span-is_used next_span-page_id span-page_id span-n_pages) { span_lists_[next_span-n_pages].Erase(next_span); page_map_.erase(next_span-page_id); span-n_pages next_span-n_pages; delete next_span; } } // 3. 重新登记合并后的span page_map_[span-page_id] span; span_lists_[span-n_pages].PushFront(span); }这段代码有两个值得强调的细节点。第一个细节向前合并后span指针已经指向原来的prev_span了这时候必须继续向后检查因为合并后的新span后面可能还紧跟着另一个空闲span。有些实现只在向前合并后顺手做一次向后检查然后直接结束结果漏掉了连续两次向前合并的可能。我的写法是先循环向前、再向后实际测试中比较稳妥。更严谨的写法是用while循环包住前后合并直到两边都不满足条件虽然理论上有更长的合并链但两轮检查已经能覆盖绝大多数场景。第二个细节从span_lists_里摘除旧span、更新page_map_映射时顺序不能乱。我的经验是先摘除链表、再删除旧映射最后调整新映射。如果反过来先改了page_map_万一此时另一个线程通过PageCache的全局锁等待而本线程在后续操作中异常退出映射表和链表的实际状态就可能不一致。虽然异常路径并不多但这种状态不一致的bug一旦出现极难复现。4.3 合并时边界条件的处理合并代码中span-page_id 0的判断非常重要。如果page_id 0那page_id - 1就是无符号数的最大值查unordered_map会得到一个无效结果容易掩盖真正的边界问题。同理向后合并时要先确认page_map_[span-page_id span-n_pages]这个元素存在再判断两span是否真的相邻。判断相邻的公式我额外解释一下对于前一个spanprev_span判断它是否与当前span相邻只需验证prev_span-page_id prev_span-n_pages span-page_id。如果相等说明prev_span覆盖的页区间[page_id, page_idn_pages)的终点正好是当前span的起点中间没有空洞。如果不等哪怕只是差了一页也不能合并。这个判断在逻辑上等价于检查prev_span的下一页是否是当前span。5. 内存初始化与系统申请的实现细节5.1 SystemAlloc直接向系统要内存的接口PageCache向系统申请内存时我采用的是最直接的方式调用malloc申请指定页数的字节数然后按页大小对齐。教学项目和轻量级项目这么做足够因为它简单、可移植、不需要处理mmap的系统差异。如果你做的是高并发生产级项目就得考虑用系统级映射来获取大块内存避免走malloc的内部锁和额外元数据开销。void* PageCache::SystemAlloc(size_t n_pages) { size_t total_bytes n_pages PAGE_SHIFT; void* ptr malloc(total_bytes); // 如果希望起始地址页对齐可以额外做一次对齐调整 return ptr; }这里必须明确一点用malloc申请的内存在释放时必须使用free不能使用delete更不能混用其他释放接口。这个看似基础的规则在实际项目中反而经常翻车——因为span的合并会跨span操作很多人下意识调用delete来释放一个Span对象时其实释放的是Span结构体本身而不是span对应的内存块。为了区分我通常建议把span的内存块分配和span控制块分配彻底分开控制块用new Span()数据内存块用malloc/free或者系统调用二者不要混淆。5.2 为什么页号计算用地址右移而不是除以大小页号的标准计算方式是(size_t)ptr PAGE_SHIFT而不是(size_t)ptr / PAGE_SIZE。两者在数学上等价但右移更快、意图更清晰。同理12位页内偏移加上PAGE_SHIFT12正好对应4KB大小。一个我常被问到的点如果malloc返回的地址只按16字节对齐并不保证4KB对齐那页号计算还有效吗其实有效。page_id只是让记录“这个内存位于哪个页面”即使起始地址不在页边界也能通过地址右移得到一个整数页号。但在实际使用中如果内存池后续要把span按页管理最好还是要求系统申请结果按页对齐。最稳妥的做法是申请时多申请一个页大小的额外空间并把起始地址向上对齐到页边界释放时再回溯原始指针释放。不过这个方案会引入额外的控制头教学项目里通常不这么做。5.3 大内存请求的路径与限制当CentralCache需要的span页数超过128页时NewSpan里的循环上限不足以支持需要单独处理。我采取的策略是如果k 128PageCache直接向系统申请k页的span不经过桶缓存。这样做可以让超大对象的分配直接命中系统不必在PageCache里长时间占据大块span。但代价是这块内存在释放合并后很难和其他span合并成更大的块因为旁边通常没有相邻的空闲span。另一种策略是把大请求切成多个不超过128页的span分别管理再用某种方式记录它们属于同一个逻辑大块。这个方案在内存利用率上更优但实现复杂度明显上升。面试时能说清楚两种方案的取舍即可工程实现不要为了炫技把复杂度推得太高。6. 常见问题与排查经验实录6.1 合并后出现“幽灵span”“幽灵span”是指page_map_里记录的span和实际链表里的span不一致。典型场景是向前合并后代码中更新了page_map_的键为prev_span-page_id但忘了删除原来span-page_id对应的旧记录。结果就是两个键同时指向同一个merge后的span后续任何一个键触发释放都会把这个span重复操作一遍。排查方法很直接写一个SanityCheck遍历page_map_中所有span验证每个span的page_id是否等于它在映射表中的键。一旦发现映射键和span-page_id不匹配立刻断点定位。这种bug的成因几乎全都在映射表更新不彻底不要在链表操作上浪费时间。6.2 切分后剩余span无法被再次分配如果remain_span被挂到了span_lists_[remain]但page_map_里没有登记就会出现这种现象后续请求remain页的span时返回的是其他内存或空指针而真正的那块空闲内存一直躺在链表里被遗忘。这类问题最常见的诱因是切分发生在递归调用NewSpan(k)之前而NewSpan(k)内部又会做一次映射表检查结果发现新切出的span没登记误判为需要再次向系统申请。推荐的做法是在NewSpan所有分支里统一通过一个辅助函数登记映射而不是在切分代码里手动散落地insert。6.3 多线程并发释放导致合并时互相踩踏PageCache的合并操作必须在全局锁保护下进行。如果并发释放时两个span物理相邻但两个线程同时进入ReleaseSpanToPageCache各自拿着自己的span做合并判断就会出现前后邻居状态不一致线程A看到的next_span正被线程B释放但标记还是is_used false线程B那边的合并结果还没落盘线程A已经基于旧信息操作了。解决方法是确保所有对span_lists_和page_map_的写操作都发生在mutex_临界区内并且合并逻辑从头到尾只依赖临界区内的最新状态。实测下来把ReleaseSpanToPageCache整个函数体都用std::lock_guard包住是最省心的锁粒度大一点带来的性能损失在内存池这个层级上几乎可以忽略。6.4 内存只涨不降的初步判断如果你发现进程内存持续增长先别急着怀疑PageCache泄漏。优先做三件事打印每次向系统申请的总页数和当前空闲总页数看两者是否保持合理比例检查page_map_里是否有大量is_used false但页数极小的span这往往是合并失败导致的碎片堆积检查CentralCache归还span的回调是否真的被触发我之前调试一个并发压测下的内存增长问题最后定位到CentralCache里对象归还的free_list头指针更新丢失导致部分span根本没回到PageCache和PageCache自身的逻辑半毛钱关系没有。所以排查时一定要把问题边界划清楚不要一上来就怀疑最底层的模块。6.5 一个值得保留的调试工具函数这里分享我平时代码里一直保留的调试函数在排查切分和合并问题时帮了大忙void PageCache::DumpPageMap() { std::vectorstd::pairsize_t, size_t spans; for (auto [id, span] : page_map_) { spans.emplace_back(id, span-n_pages); } std::sort(spans.begin(), spans.end()); for (auto [id, n] : spans) { printf(span: start%zu pages%zu used%d\n, id, n, page_map_[id]-is_used ? 1 : 0); } }当release过程结束后DumpPageMap的输出应该满足一个隐含条件所有span的区间[start, startn)互不重叠。任何重叠都说明映射表更新或合并逻辑有误。这个工具函数我建议留在线上代码里它以极低的成本换来了极高的可观测性。7. 最后一个易错点别把Span回收和内存释放混为一谈新手最容易犯的一个错是在PageCache里看到“释放span”字样就在代码里写delete span。这里必须分清楚两种释放释放span的控制块Span*指向的结构体本身——用delete span释放span背后的数据内存块一整片页内存——用free或对应的系统释放接口正常流程中PageCache回收span是把span重新挂到空闲列表并尝试合并数据内存永远不归还给操作系统因为PageCache的设计目标就是复用这块内存。只有当你确定这个span被合并成超大片且系统不再需要时才需要真正释放数据内存。但现实中这个决策很难做很多高性能内存池干脆不还内存给系统直到进程退出。所以我的建议是在教学项目和大部分后台服务场景中PageCache层面的内存只做复用不做归还。如果你希望优雅地降内存那至少要把合并后超过一个阈值比如256页的空闲span才考虑真正释放防止频繁向系统要内存。这个阈值选得越小内存占用越平稳但系统调用次数也会相应增加需要根据线上负载调参。个人在实际开发里最深的体会是高并发内存池这个项目ThreadCache和CentralCache写起来都很“带感”每个分支都有明确的功能和视觉反馈PageCache则更像是幕后工作写的时候枯燥调试起来隐蔽可一旦这块地基不稳固上面两层无论怎么优化都会在并发压力下崩出各种诡异问题。把PageCache的切分、合并、映射三件事真正做到逻辑闭环后面加上压测和并发热点分析就会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑