写完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定义,后面所有逻辑都围绕它展开:
// 页大小固定为4KB,1 << 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::array<SpanList, 128> span_lists_; // 页号到span的映射,用于合并时快速查找 std::unordered_map<size_t, Span*> page_map_; std::mutex mutex_; // 全局锁 // 向系统申请n页内存 void* SystemAlloc(size_t n_pages); };我这里用的std::array<SpanList, 128>,下标直接对应该span的页数。span_lists_[0]通常不用,span_lists_[1]挂的是1页的span,span_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。那就需要从更大的桶里“借用”。具体做法是:从k+1开始向上遍历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_cast<size_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_id+n_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_SHIFT=12,正好对应4KB大小。
一个我常被问到的点:如果malloc返回的地址只按16字节对齐,并不保证4KB对齐,那页号计算还有效吗?其实有效。page_id只是让记录“这个内存位于哪个页面”,即使起始地址不在页边界,也能通过地址右移得到一个整数页号。但在实际使用中,如果内存池后续要把span按页管理,最好还是要求系统申请结果按页对齐。最稳妥的做法是:申请时多申请一个页大小的额外空间,并把起始地址向上对齐到页边界;释放时再回溯原始指针释放。不过这个方案会引入额外的控制头,教学项目里通常不这么做。
5.3 大内存请求的路径与限制
当CentralCache需要的span页数超过128页时,NewSpan里的循环上限不足以支持,需要单独处理。我采取的策略是:如果k > 128,PageCache直接向系统申请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::vector<std::pair<size_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, start+n)互不重叠。任何重叠都说明映射表更新或合并逻辑有误。这个工具函数我建议留在线上代码里,它以极低的成本换来了极高的可观测性。
7. 最后一个易错点:别把Span回收和内存释放混为一谈
新手最容易犯的一个错是:在PageCache里看到“释放span”字样,就在代码里写delete span。这里必须分清楚两种释放:
- 释放span的控制块(
Span*指向的结构体本身)——用delete span - 释放span背后的数据内存块(一整片页内存)——用
free或对应的系统释放接口
正常流程中,PageCache回收span是把span重新挂到空闲列表并尝试合并,数据内存永远不归还给操作系统,因为PageCache的设计目标就是复用这块内存。只有当你确定这个span被合并成超大片且系统不再需要时,才需要真正释放数据内存。但现实中这个决策很难做,很多高性能内存池干脆不还内存给系统,直到进程退出。
所以我的建议是:在教学项目和大部分后台服务场景中,PageCache层面的内存只做复用,不做归还。如果你希望优雅地降内存,那至少要把合并后超过一个阈值(比如256页)的空闲span才考虑真正释放,防止频繁向系统要内存。这个阈值选得越小,内存占用越平稳,但系统调用次数也会相应增加,需要根据线上负载调参。
个人在实际开发里最深的体会是:高并发内存池这个项目,ThreadCache和CentralCache写起来都很“带感”,每个分支都有明确的功能和视觉反馈;PageCache则更像是幕后工作,写的时候枯燥,调试起来隐蔽,可一旦这块地基不稳固,上面两层无论怎么优化都会在并发压力下崩出各种诡异问题。把PageCache的切分、合并、映射三件事真正做到逻辑闭环,后面加上压测和并发热点分析就会顺畅很多。