
1. 背景MGLRU把readahead的folio当成“冷数据”回收了先还原一个真实场景。前阵子我在调一个内存只有64G的搜索节点索引文件大概40G正常情况下全部能留在page cache里。只要内存一有回收压力服务延迟立刻上了一个台阶而且不是偶发是每次memory reclaim之后都会持续几分钟的“虚弱期”。用tracepoint一抓回收名单里出现大量还没被映射到进程地址空间的readahead folios。这其实不是什么新鲜事MGLRUMulti-Generational LRU对page cache的冷热判断一直很激进尤其是对“预读进来但还没被进程真正map”的folio非常容易误判成冷页。这里说的map不是map函数那个map是进程地址空间里的文件映射也就是page fault把page cache里的folio填充进PTE的过程。MGLRU的回收器盯的是folio的访问历史和分代位置而readahead产生的folio在预读完成到进程真正访问之间的窗口期恰好没有任何可观测的访问信号于是就成了回收器眼里的“垃圾”。这个问题的本质是readahead的价值和MGLRU的冷热信号之间存在错位readahead的价值在于“提前把数据放在缓存里等进程来用”而MGLRU在缺少访问信号时倾向于尽早释放缓存两个逻辑会打架。我在整理系列问题的时候把这个排成“问题5”因为它影响面很大——凡是依赖顺序读、mmap或fio等场景的机器只要开MGLRU都可能踩到。这篇文章不打算泛泛介绍MGLRU而是围绕一个跟这个场景紧密绑定的应对思路延迟到folio被map时再激活readahead的folios。读完你可以理解为什么这个信号选得准、实现里要动哪些路径、实测效果怎么样以及还有哪些边界问题没有完全收口。2. 预读好的folio为什么在MGLRU里活不过一轮扫描要搞懂应对方案先得明白MGLRU是怎么看待readahead folios的。MGLRU把每个lruvec里的folio按访问时间分成多个generation代最新的代叫young最老的代叫old。回收器从old代开始找可回收页只有被访问过的folio才有资格“升代”。它内部靠两个东西判断热度一是folio的access bitPTE中的young位、page table的accessed位二是folio在分代列表里的refs计数refs本质上是一个“这一代里被访问了几次”的统计。readahead的folio生命周期是这样的预读模块从磁盘读入数据后folio被加入到page cache同时可能带着PG_readahead标志。这个阶段folio并没有映射到任何PTE也就是说它的accessed bit不会因为进程访问而被硬件置位。如果进程是纯粹的顺序读理论上马上会有page fault来消费这些缓存页中间窗口很短。但现实场景往往是发起预读之后进程还要经历用户态调度、锁等待、网络事件等真正触发map的fault可能延迟几百微秒甚至几毫秒。对MGLRU来说这几毫秒内folio的状态就是“无访问、无映射、refs为0”然后它就会随着folio的age增长逐步向old代滑动。关键问题出在folio这个大页抽象上。一个folio可能包含多个物理页比如一个order-4的folio能装16个page cache页。MGLRU在做分代管理时是以folio为单位的。假如这个folio里有1个page被映射并且被访问了整个folio会被视为激活过反之如果这个folio还是“处女folio”没有一个page被map那么它连最基本的“被引用过”信号都没有反映出来。folio越大误判的代价越高——一次误回收丢掉的不止一个page而是一整段连续数据。我在排查时用perf和tracepoint验证过这个链路。在MGLRU的shrink_folio_list流程里对没有映射且没有被访问过的folio走的是默认的“快速回收”分支直接把folio标记为reclaimable后续从inactive list摘掉。如果你在内核里打一个tracepoint观察folio的flag会发现这些folio既没有PG_active也没有PG_referenced甚至很多连PG_readahead都被清掉了已经完全无法看出它是被预读进来的。换句话说等到回收器看到它的时候它已经和一次普通读进来的冷数据没有任何区别。这也是为什么很多人试图靠调整MGLRU参数来救它实际效果都很差因为信号在源头就丢了。真正要解决的是“如何让MGLRU在folio还没有使用痕迹时依然相信它未来有价值”。3. 常规解法逐一试过最后发现都斗不过“缺少使用痕迹”3.1 延长folio在old代中的停留时间第一种思路很直观既然readahead folios太容易被回收那就让它们在被创建后的一段时间内保持“年轻”比如强制提升generation或者在folio_update_gen里放宽age阈值。我试过把MGLRU的默认最小回收年龄从4代调大到8代确实能让一部分预读页在短期内不被回收但问题在内存压力大的时候立刻暴露一旦旧代被压低新readahead会大量进入young代把真正的热页挤出年轻人。搜索场景里最要命的是元数据热页它们本身在young代里待不了太久被新涌入的readahead一冲回收器就开始在hot区反复扫荡。这个方案的另一个问题是“无差别保护”。它保护的是所有年轻folio包括那些确实冷、只是碰巧在创建早期被流程访问过的页面结果内存里堆满了半热不热的数据有效缓存利用率反而下降。3.2 预读完成后立刻激活既然延后回收不精确那“预读做完就激活”总可以吧实际上也不行。readahead的设计初衷是“提前准备”不是为了“立即消费”。在很多业务里readahead和真正消费之间间隔很长比如一个进程预读了一个大文件的前256MB但程序逻辑要先解析前面的头部结构后面的数据可能要几十秒后才访问。如果预读一完成就把这些folio激活到young代那么young代会被塞进大量“看起来热闹、实际没人碰”的数据等真正热的数据需要保护时分代空间已经被占满。激活本身也分层次我当时的实验是在readahead完成回调里直接调用folio_activate。这样做的结果是整机内存压力不高的时候一切正常一旦并发数和缓存命中率拉满系统的major fault没有明显改善反而因为young list过长导致回收器在“收缩young代”上多花了不少CPU。简单说readahead不是访问预读完成这个事件本身不能作为“热”的证据。3.3 在回收路径里看到PG_readahead就放行还有人会想既然PG_readahead标记存在回收器看到它就不回收不就行了这个想法看起来很安全但内核里标记的含义和你想的不一样。PG_readahead不是“这块page需要保留”的标记它只是告诉内核“这次预读已经发生下一次顺序预读可以启动了”通常等下一次readahead事件发生时就会被清除。你根本没法确定一个带有PG_readahead的folio到底算不算“未来会被读”因为readahead可能只预读了几页进程还没触达它们就已经切走。而且在folio被映射之后的路径里PG_readahead一般会被清零导致回收器根本无法区分这个folio是普通读还是预读。真正可靠的区别信号只有一个这个folio是否已经通过PTE映射到了某个进程的地址空间。只要还没有map它就有可能是“被预读了但还没消费”一旦map了说明进程的fault已经触达接下来马上会有真实访问。这些尝试让我确认提前保护、延长年龄、看标志位都是“基因层面的修补”真正该做的是在folio从一个状态切换到另一个状态的转折点给回收器提供一个明确的信号。4. “map时激活”方案选择一个真正有区分度的信号4.1 map这个动作为什么值得信赖读文件的时候进程往地址空间里填PTE只有两种路径一是page fault进来发现page cache里已经有folio直接建立映射二是readahead预读完成之后进程的下一次顺序读fault正好命中同一个folio同样会建立映射。无论哪条folio的_mapcount都会从0变成正数也就是说这个folio“被消费”是确定性事件而不只是“有可能被消费”。把激活动作设计成“延迟到map时再做”本质上是把判断权从“预读元数据”移交给了“进程的实际访问行为”。这里有个微妙的点map动作本身不一定立刻产生真正的读访问比如进程做了madvise不想访问或者用户态和PTE建立之间还有很长的间隔。但在绝大多数顺序读场景下map和read之间几乎是无缝的。相比预读完成时激活map时激活已经过滤掉了大量“预读后迟迟不访问”的浪费相比完全不做保护它又保住了readahead的缓存价值。4.2 实现切入路径该在filemap_map_pages还是do_set_pte从代码路径来看map一个page cache folio最终都会落到do_set_pte或者它前面的流程里。readahead folio被进程访问时fault处理会有两条分支如果folio已经在page cache走filemap_map_pages快速路径如果folio不在则走do_read_fault从磁盘读出之后再做映射。方案里需要同时覆盖这两条路因为readahead的命中可能走快速路径而miss则会在fault过程中重新从块层读取。我建议在filemap_map_pages成功建立映射并设置了PTE之后检查该folio是否带有readahead相关属性再执行激活。这样不会干扰fault路径的核心逻辑也能保证folio确实已经map成功。如果放在do_set_pte之前有可能fault后续会因为其他原因失败导致folio没有真正建立映射激活动作就有点“名不正言不顺”。4.3 识别readahead folio不能只靠PG_readahead这里有个坑必须提醒。PG_readahead标志在上文已经说过它是会变化的。为了准确识别“由readahead分配的folio”更可靠的做法是在struct folio的private字段里打一个位或者在分配路径里用专门的标志。但现实中很多人不想动struct layout那么至少要在readahead分配folio时先检查一遍标志并且保证在folio即将被map的瞬间还能读到它。我实际用的逻辑是这样的在readahead分配函数里给folio置上PG_readahead同时加一个自定义folio flag比如放在PG_private_2这种内核对用户可见性影响小的位上。在page fault建立映射后检查这个标志是否存在存在则执行folio_activate并在激活后马上清除自定义flag保证同一folio后续不会再被重复激活。这种方式比单纯依赖PG_readahead要稳因为你希望在“映射的那一刻”还能拿到当时的身份信息而不是等fault完成之后靠一个可能已经清零的位去猜。4.4 激活动作落到MGLRUgen提升与refs踩坑MGLRU下最直接的激活方式是调用folio_activate()它会走lru_gen_activate让folio从当前gen跳跃到当前最年轻的generation。如果你只是简单调用底层folio_inc_gen要注意函数需要传入正确的max_gen和refs参数。在MGLRU里“激活”其实包含两层含义一是把folio移到最新代二是把它的refs重置为1避免它刚进young代又被下一轮扫描当老化对象刷掉。这里有一个和传统LRU很不一样的地方MGLRU不看你是否最近被访问过它看你在第几代以及refs是多少。所以即使folio在map之后没有立刻产生读访问只要它是最新代的一员在normal memory pressure下就不会被优先回收。这正好给了后续真实读操作一个宽限期。但同时也要防止滥用如果map时对所有readahead folio都无脑升到最新代young代会迅速膨胀。这也是后面要讲到的副作用所在。4.5 与旧内核传统LRU的兼容如果你在带MGLRU的内核上工作直接走folio_activate没问题。如果内核还在传统LRUinactive/active双链的模式下同样思路也可以落地在map路径里调用mark_page_accessed或folio_activate把folio从inactive list提升到active list。传统LRU虽然没有“代”的概念但“激活”动作等价于告诉回收器“这个folio刚被使用别优先动它”。这个方案的好处是不依赖MGLRU的具体实现只要你控制好触发点无论新旧内核都能统一。我在本地内核上先是打了MGLRU版本的补丁验证通过后又改了一版传统LRU上的两边逻辑基本一致map到folio - 检查readahead标志 - 执行激活 - 清标志。区别只在于传统LRU没有gen提升的安全阀更容易被一些madvise的干预影响所以对“map后不读”的副作用要更保守一些。5. 压测下的效果readahead命中率回升但也看到新问题5.1 测试环境和负载构造验证这台机器比较脏所以我单独用了一台裸金属服务器CPU是双路48核内存128GNVMe SSD。测试负载分两层一层是统一的“内存压力制造机”周期性地匿名分配和释放40G内存触发MGLRU回收另一层是实际的顺序读workload一个进程用mmap映射一个20G的文件然后从尾到头顺序访问每一页。同时在另一个线程里加入rss统计专门观察major fault数量和读IO次数。对照组有三个不开启MGLRU传统LRU、开启MGLRU但无patch、开启MGLRU并带上map时激活patch。每组跑30分钟数据取后半段稳定状态。5.2 数字变化fault延迟和磁盘读次数都降了最关键的两个指标如下指标传统LRUMGLRU无patchMGLRUmap激活patchmajor fault次数每万次访问2311598287磁盘读请求数相对值1.002.361.12p95 fault处理延迟us180520210young代平均长度folio数37万82万95万MGLRU无patch时major fault高得离谱主要是两个原因叠加一是预读出来的folio被回收进程每次fault都要重新读盘二是被回收后又重新预读形成抖动循环。加了patch之后disk读请求回落到接近传统LRU说明readahead的大部分价值被保住了。p95 fault延迟也从520us降到了210us这里的收益比读次数下降更明显因为少了很多“fault等磁盘”的同步路径。5.3 young代膨胀是药效之后的必然副作用patch之后young代平均长度从82万涨到95万涨幅大概16%。这在没有内存压力的时候问题不大一旦内存压力持续拉高MGLRU会先收缩young代膨胀并不意味着性能立刻崩坏但确实会提高回收器扫描young代的CPU开销。我在压测里专门把内存压力调高到接近100%让内存分配器反复触发direct reclaim这时patch版本在CPU消耗上比无patch多出约3%主要是因为young代里混进了一批“只map但不读”的folio。站在纯内存回收的角度看这是可以接受的代价。readahead页如果map后不读最多也就是多占用一段时间内存等它慢慢老化再到被回收和传统LRU的“active list dwell time”很像而如果map后立刻读那这些folio本来就是该保的热数据young代膨胀反而是把正确的页放在了正确的位置。5.4 场景不适合的时候重随机读、内存逼近极限需要强调这个方案不是所有场景都能无脑开。如果你的workload是随机读密集型每次fault都换一个位置那readahead的价值本来就很低map时激活会把大量一次性读的folio推进young代白白占用内存。另一个不适合的场景是内存极度紧张剩余可回收内存已经低于阈值这时young代膨胀会让回收器在每轮扫描里多花时间反而提高了直接回收的次数。我个人的判断是这个方案适合顺序读、mmap密集型服务比如索引加载、日志回放、数据导入。复杂事务型OLTP或者随机点查场景收益很小副作用反而会被放大。6. 后续踩坑记录flag清理、竞争条件和“假激活”6.1 map后不读的folio怎么处理这是所有做过这个方案的人都会撞到的第一道坎。map时激活不等于“永久激活”folio在young代里也会老化如果map之后进程迟迟没有真正read它最终还是会随着时间推进滑向old代。理论上这没问题但我在初始版本里踩过一个坑我在激活时仅提升了gen而没有重置refs导致某些folio明明在young代里待了很久却因为refs一直很大被当成“访问频率很高”的对象长时间霸占内存。这个其实跟MGLRU的refs统计逻辑有关。我的建议是激活时明确重置refs为1后续再依赖真实访问来累加避免假高温。6.2 共享映射和私有映射的差异MAP_SHARED和MAP_PRIVATE在这个方案里行为差别很大。共享映射里folio本来就是page cache的一部分map时激活保护的是一份数据私有映射里map之后写操作会触发COW一旦COW发生folio的映射版本就会和page cache分家。如果我在map时激活了readahead folio但进程下一秒就写它造成COW那这份激活实际上是把“原始版本”保住了对“COW后的私有版本”没任何帮助。这会导致一种微妙现象共享映射场景下patch收益稳定私有映射下patch的效果随机性比较大。6.3 PG_readahead标志的清理时机问题我建议在map路径激活后立即清除自己加的自定义标志。如果不清除之后folio可能因为某些相同标志的判断被二次激活比如一个folio被map两次第一次激活后标志还在第二次map时又激活一次造成young代重复计数。清理的代价很低但很多人容易忽略。顺便说一句PG_readahead本身的清除不用我们管内核内部的readahead流程自会处理。但如果你直接在folio_activate里判断PG_readahead就必须意识到它可能被清除或重新置位的时机否则会出现map时已经检测不到readahead身份的情况导致patch失效。6.4 后续更进一步等“真实read”而不是“map”再激活map时激活已经比预读时激活精确很多但还不是最精确的方案。最理想的是folio进入page cache后先不标热等第一次真实读访问发生时再激活。这样既不损失预读价值又完全避免map后不读造成的young代膨胀。实现上会比map激活更麻烦因为你需要跟踪到fault之后的第一次页表访问或者在readahead完成后的下一个fault里做二次判断。我在实验中发现大部分顺序读场景下map和第一次read几乎重叠map方案已经足够如果你要应对更多特殊workload可以在这个方向上继续做。另外还有一个待解的边界folio在map前就被回收了然后进程fault重新从磁盘读入这个fault新建的folio不带任何readahead标志走不到激活路径。这种情况虽然少见但做大规模数据恢复时会出现。我的临时处理是在__readahead机制里同时检查PG_readahead和folio是否被重新从磁盘读取一旦出现就同样激活避免第二次循环。7. 一些操作上的经验和最后的建议如果你也想在自己的内核版本里做这个改动有几个点先确认一下。第一先跑一遍你的workload用tracepoint统计回收时被释放的folio里有多少比例带PG_readahead或者更直接一点统计被释放的folio中有多少的mapcount是0。如果这个比例很低说明你的场景不受此问题困扰没必要引入patch。第二实验阶段把patch收敛在一个可开关的sysctl后面方便对比数据也方便出了问题快速关掉。第三压测时内存压力不能太低否则young代膨胀的副作用看不出真实影响必须把内存压缩到接近临界点再观察。我在实际把这套方案部署到索引服务后最大的体感是内存回收导致的P99抖动明显减少。过去每次回收高峰过后总会有一两分钟的“cache真空期”现在这个真空期基本不见了因为真正等进程去map的预读页不会被白白丢掉了。这个改动本身不复杂十几行代码的事但它背后反映了一个很根本的道理内存回收器判断“热”与“冷”时最好以进程的实际动作而不是内核自己的预判为准。readahead生成的数据归根结底要服务于map之后的真实访问那把激活时机放到map这个边界上是逻辑最自洽的选择。