文件页回收流程对比:主线 Linux vs Android(源码级)
范围:只讨论文件页(page cache + shmem)从"被回收器选中"到"物理页归还 buddy"的回收流程本身。zram、lmkd 等外围机制仅在与该流程交汇处出现。
基线:
- 主线:torvalds/master(
mm/vmscan.c8131 行)- Android:android15-6.6 GKI(Android 15 出厂内核,
mm/vmscan.c8470 行,含 49 处 vendor hook);配置参照其gki_defconfig,运行时参数参照 AOSProotdir/init.rc源码快照已下载至本工作区
src/,以下全部行号可复核。
0. 流程总览
分配变慢 → 水位不足(WMARK_LOW/MIN)
└─ kswapd 后台回收 / 进程直接回收 → shrink_node()
│
├─ 阶段① 扫描平衡 get_scan_count()
│ "file LRU 扫不扫?扫多少?" ← refault 成本反馈(阶段⑤)
│
├─ 阶段② LRU 选取与隔离
│ 传统:shrink_active_list() 降级 + isolate_lru_folios() 摘链
│ MGLRU:get_type_to_scan() → get_tier_idx() → scan_folios()
│
├─ 阶段③ 单页状态机 shrink_folio_list()(六道关口)
│ trylock → 引用检查 → writeback 判定 → unmap → 脏页处理 → 摘除释放
│
├─ 阶段④ 摘除 page cache:__remove_mapping()(留 shadow)→ free
│
└─ 阶段⑤ 反馈回路:重故障时算 refault distance → 修正阶段①
Android 没有另写一套回收器——vmscan.c 的流程骨架与主线同源。它的改动是嵌进这条链的每一阶段:阶段①有行为改写,阶段②③插了十几处决策 hook,阶段⑤则基本不动。下文逐阶段对齐。
1. 主线 Linux:文件页回收流程五阶段
阶段① 扫描平衡 —— get_scan_count()(src/mainline/mm/vmscan.c:2550)
决定 anon/file 两条 LRU 各自的扫描量,按优先级依次判定(scan_balance 五个取值):
| 判定条件 | 结果 | 行号 | 含义 |
|---|---|---|---|
无 swap / may_swap=0 |
SCAN_FILE |
2578 | 文件页独扛全部回收(无 swap 服务器的常态) |
| memcg 回收且 swappiness=0 | SCAN_FILE |
2591 | 单 cgroup 禁止换出 |
| priority=0(濒临 OOM)且 swappiness≠0 | SCAN_EQUAL |
2601 | 等量扫两类 |
sc->file_is_tiny |
SCAN_ANON |
2609 | file LRU 快抖死,强制转向(见下) |
sc->cache_trim_mode |
SCAN_FILE |
2619 | 不活跃缓存多且不抖 → 只裁缓存不换出 |
| 其余 | SCAN_FRACT |
2624 | 按成本比例分配 |
两个状态位由 prepare_scan_count() 预先算好:
- cache_trim_mode(2395-2405):
file >> priority且 file LRU 无抖动迹象 → "有大量不活跃、不在抖动的文件页",回收优先裁它们,不碰匿名页; - file_is_tiny(2407-2441,防 "cache trap"):文件 LRU 缩到
file + free ≤ 全部 zone 的 high 水位之和且匿名页仍可观时,禁止继续只扫文件页——否则"cache 越小越被扫、越扫越小"正反馈失控:
sc->file_is_tiny =
file + free <= total_high_wmark &&
!(sc->may_deactivate & DEACTIVATE_ANON) &&
anon >> sc->priority;
SCAN_FRACT 时的成本模型在 calculate_pressure_balance()(2444-2479):每条 LRU 的"回收成本"来自其 refault 率(阶段⑤的反馈),按 swappiness 加权反比分配扫描压力;swappiness=100 视为两类 I/O 成本相等。
阶段② LRU 选取与隔离 —— 两种实现并存
传统双链(CONFIG_LRU_GEN 未开或 per-lruvec 未启用):
shrink_active_list()(2065):活跃链 → 不活跃链的"降级"。先folio_check_references()查引用,被引用的留在活跃链;同时VM_EXEC文件页首次使用即激活("可执行文件页优先驻留",android15-6.6 对应逻辑在 2869-2874,主线同源);isolate_lru_folios()(1679):从 inactive 链头摘nr_to_scan个候选页。
MGLRU(主线 6.1+,世代×类型×zone 组织,MAX_NR_GENS=4、文件页按访问热度 MAX_NR_TIERS=4):
get_type_to_scan()(4822):比较两类各 tier 的 refault 率+swappiness,选先扫 anon 还是 file;get_tier_idx()(4802):选 refault 率最低(最冷)的 tier;scan_folios()(4727)从最老世代隔离,期间sort_folio()(4654)逐页分诊:
/* protected */
if (tier > tier_idx || refs + workingset == BIT(LRU_REFS_WIDTH) + 1) {
gen = folio_inc_gen(lruvec, folio); /* 提回新世代 */
...
return true;
}
"经 fd 二次访问"的热文件页因 tier 高而被保护,不会与一次性扫描页一起被逐出——这是 MGLRU 对文件页回收质量的直接提升。
阶段③ 单页状态机 —— shrink_folio_list()(1058-1512)
对隔离出的每个候选页,依次过六道关口,任何一关不过就把页放回 LRU(keep/activate):
关口 1 · 加锁与资格(1089-1118):folio_trylock 失败 → keep;folio_evictable 为假(mlocked 等)→ activate;!may_unmap && folio_mapped → keep。
关口 2 · 引用检查(1231-1242):folio_check_references()(android15-6.6:1575 逐行展开)做反向映射查引用:
if (referenced_ptes) {
folio_set_referenced(folio);
if (referenced_folio || referenced_ptes > 1)
return FOLIOREF_ACTIVATE; /* 近期被访问过 → 回活跃链 */
if ((vm_flags & VM_EXEC) && folio_is_file_lru(folio))
return FOLIOREF_ACTIVATE; /* 可执行文件页一票保护 */
return FOLIOREF_KEEP; /* 第一次引用 → 再绕一圈 */
}
关口 3 · writeback 判定(1141-1229):回收路径遇到正在写回的页,三案例——kswapd 高压循环(activate 让路)/ 普通(设 PG_reclaim 标记等下一轮"立即回收")/ 旧式 memcg(等写回完成)。
关口 4 · unmap(1328-1357):try_to_unmap() 摘掉进程页表映射;失败 → activate。
关口 5 · 脏页处理(1370-1435)——文件页回收的关键分叉:
if (folio_test_dirty(folio)) {
if (folio_is_file_lru(folio)) {
/* Immediately reclaim when written back. */
node_stat_mod_folio(folio, NR_VMSCAN_IMMEDIATE, nr_pages);
if (!folio_test_reclaim(folio))
folio_set_reclaim(folio);
goto activate_locked; /* 不写!标记后放回 */
}
... /* pageout() 仅处理 anon/shmem */
}
主线对脏文件页的策略是绝不主动写盘:打上 PG_reclaim 标记放回,等 writeback 子系统(脏页比率驱动)洗白后,下一轮回收按标记"立即回收"。pageout()(621-676)注释明说:
/*
* We no longer attempt to writeback filesystem folios here, other
* than tmpfs/shmem. That's taken care of in page-writeback.
*/
脏页大量积压时回收只做一件事——踢 flusher:wakeup_flusher_threads(WB_REASON_VMSCAN)(1958)。
(版本差异注:6.6 基线(含 android15-6.6:2084-2111)仍保留"kswapd + PGDAT_DIRTY + 已带 reclaim 标记"三条件齐备时由 pageout() 兜底写脏文件页的最后手段;master 已删除该分支。这是主线版本演进,非 Android 改动。)
关口 6 · 摘除与释放(1460-1512):
filemap_release_folio():让文件系统无 I/O 地释放页内 buffer;__remove_mapping()→ 干净未映射文件页直接从 page cache 摘除(零 I/O,文件页回收的理想路径);free_unref_folios()归还 buddy。
阶段④ 摘除时的信息埋点 —— shadow entry
__remove_mapping 内(主线 1495、android15-6.6:1498-1505):文件页被摘除瞬间调用 workingset_eviction() 在 page cache XArray 原位置留下 shadow entry,编码 memcg id + 逐出时钟 + 访问热度:
if (reclaimed && folio_is_file_lru(folio) &&
!mapping_exiting(mapping) && !dax_mapping(mapping)) {
shadow = workingset_eviction(folio, target_memcg);
}
__filemap_remove_folio(folio, shadow);
阶段⑤ 反馈回路 —— refault distance
被逐出的文件页再次故障进来时(mm/workingset.c):
refault_distance = ((refault - eviction) & EVICTION_MASK); /* :511 */
return refault_distance <= workingset_size; /* :536 */
distance ≤ 当前活跃工作集大小 → 判定"逐出得太早",folio_set_active() 直接放回活跃链(577-588),计入 WORKINGSET_ACTIVATE_FILE。该统计回流到阶段①:prepare_scan_count() 用 ACTIVATE_FILE 增量判断 file LRU 是否在抖(决定 may_deactivate/cache_trim_mode,主线 2385-2391),calculate_pressure_balance() 用它抬 file_cost 降文件页扫描压力。MGLRU 下同样的信息进 refaulted[hist][type][tier](workingset.c:320)驱动 tier 选择。
2. Android:同一条流程上的改动(逐阶段对齐)
2.1 阶段① 平衡决策:一处行为改写 + 一组覆盖入口
(a) android_rvh_set_balance_anon_file_reclaim —— 改写 cache_trim 偏向(android15-6.6:3189/3229-3237):
bool balance_anon_file_reclaim = false;
...
trace_android_rvh_set_balance_anon_file_reclaim(&balance_anon_file_reclaim);
/*
* If there is enough inactive page cache, we do not reclaim
* anything from the anonymous working right now. But when balancing
* anon and page cache files for reclaim, allow swapping of anon pages
* even if there are a number of inactive file cache pages.
*/
if (!balance_anon_file_reclaim && sc->cache_trim_mode) {
scan_balance = SCAN_FILE;
主线 cache_trim_mode 是硬编码偏向:"不活跃缓存够多就只扫文件页"。Android 把它变成厂商可关闭的开关——动机写在注释里:zram 在场时换出匿名页(纯 CPU 压缩)常比逐出文件页更便宜,不应让"缓存多"自动屏蔽匿名页回收。这是文件页回收流程内部最实质的一处 Android 行为改写。
(b) android_vh_tune_swappiness(3198 / 3474):分别插在传统 get_scan_count 和 MGLRU get_swappiness 里,厂商可按运行状态动态改写 swappiness(静态 sysctl 是全局一份,这里给了 per-reclaim 的动态口子)。
(c) android_vh_modify_scan_control(7062-7080,Android 新增函数 modify_scan_control()):一次性覆盖三个扫描控制量——
static void modify_scan_control(struct scan_control *sc)
{
bool file_is_tiny = false, may_writepage = true;
trace_android_vh_modify_scan_control(&sc->android_vendor_data1,
&sc->nr_to_reclaim, sc->target_mem_cgroup, &file_is_tiny,
&may_writepage);
if (file_is_tiny)
sc->file_is_tiny = true;
if (!may_writepage)
sc->may_writepage = false;
注意厂商可以把 file_is_tiny 置真——即人为触发"强制扫匿名页"的防 cache-trap 逃生门;也能关掉 may_writepage(连带收紧关口 5)。
(d) 上游水位调控:init.rc:439 在 start lmkd 前写 write /proc/sys/vm/watermark_boost_factor 0——主线默认 15000,在迁移类型回退偷页块时抬 kswapd 起扫水位(boost_watermark(),page_alloc.c:2175);Android 关掉它,避免 kswapd 被人为提前/加量唤醒而过度清缓存。
2.2 阶段② 隔离:MGLRU 默认启用 + 一组流程 hook
(a) MGLRU 出厂默认全开(gki_defconfig:134-135:CONFIG_LRU_GEN=y + CONFIG_LRU_GEN_ENABLED=y),且留了 DeviceConfig 远程灰度开关(init.rc:1317-1327):
on property:persist.device_config.mglru_native.lru_gen_config=core_and_mm_walk
write /sys/kernel/mm/lru_gen/enabled 3
...(none/core/core_and_mm_walk/core_and_nonleaf_young/all → 0/1/3/5/7)
即 Android 的文件页选取默认走"世代+tier"路径(阶段②的 MGLRU 分支),热文件页由 sort_folio() 的 protected 分支保护。
(b) 传统隔离路径的 hook:
android_vh_mm_isolate_priv_lru(2412-2415)——插在isolate_lru_folios()最顶端,且是"接管式"的:
trace_android_vh_mm_isolate_priv_lru(nr_to_scan, lruvec, lru, dst, sc->reclaim_idx,
sc->may_unmap, nr_scanned, &nr_taken);
if (*nr_scanned != nr_scanned_before)
return nr_taken; /* hook 改写过 nr_scanned → 整个原生隔离逻辑被跳过 */
厂商可以整体替换"从 LRU 摘哪些页"这一步(例如实现自己的选页策略)。
android_vh_del_page_from_lrulist(2465):每摘一页通知一次,供厂商记账。
(c) 降级路径(active→inactive)的 hook(shrink_active_list 内,2842-2854):
trace_android_vh_page_should_be_protected(folio, sc->nr_scanned,
sc->priority, &sc->android_vendor_data1, &should_protect);
if (unlikely(should_protect)) {
nr_rotated += folio_nr_pages(folio);
list_add(&folio->lru, &l_active); /* 留在活跃链,不降级 */
continue;
}
trace_android_vh_page_referenced_check_bypass(folio, nr_to_scan, lru, &bypass);
if (bypass)
goto skip_folio_referenced; /* 跳过引用检查,强制降级 */
两个方向都能调:保护指定活跃文件页不被降级(进入被逐出候选池),或强制降级(跳过引用检查)。
(d) MGLRU 扫描中止 hook:android_vh_mglru_should_abort_scan / ..._order(5571/5580)——厂商可越过"已达回收目标/水位已够"的原生中止条件,强制继续或提前中止一次扫描。
2.3 阶段③ 单页状态机:hook 最密集的环节(8 处)
按六道关口的位置逐个列出(全部在 android15-6.6 mm/vmscan.c):
| 关口 | hook(行号) | 嵌入方式 |
|---|---|---|
| 关口 1/2 之间,dirty 统计前 | vh_shrink_folio_list(1835) |
传 &activate, &keep——单页判决的提前出口,厂商可不经任何原生检查直接判 activate/keep |
| 关口 2 引用检查开头 | vh_page_should_be_protected + vh_check_folio_look_around_ref(1584/1587) |
在 folio_check_references() 最前面,返回值直接当判决用(可伪造任意 FOLIOREF_*) |
| 关口 2 内 | vh_folio_trylock_set / vh_get_folio_trylock_result(1591/1595) |
包裹 folio_referenced();trylock 失败 → 提前返回 KEEP(主线靠 referenced_ptes == -1 分支,Android 加了显式的失败通道) |
| 关口 4 unmap 前 | vh_folio_trylock_set(2062) |
标记"此页进入 unmap 回收流程",供厂商侧跟踪 |
| 匿名/shmem 大 folio 拆分 | vh_should_split_folio_to_list / vh_split_large_folio_bypass(1982/1994) |
控制大 folio 是否拆分(shmem 大 folio 是文件页流程的一部分) |
| 关口 6 摘除瞬间 | vh_keep_reclaimed_folio / vh_clear_reclaimed_folio(1502/1507) |
最终否决点,见下 |
| 状态机返回后 | vh_handle_trylock_failed_folio(2715) |
shrink_folio_list 返回后、move_folios_to_lru 前对 trylock 失败页做批处理 |
| MGLRU 同位置 | vh_evict_folios_bypass(5411) |
逐出后处理循环里跳过指定 folio 的放回逻辑 |
最终否决点值得单独看——它嵌在阶段④的 __remove_mapping 里(1502-1508):
if (reclaimed && folio_is_file_lru(folio) &&
!mapping_exiting(mapping) && !dax_mapping(mapping)) {
bool keep = false;
trace_android_vh_keep_reclaimed_folio(folio, refcount, &keep);
if (keep)
goto cannot_free; /* 最后一刻放回:页已被选中、已 unmap、
已过脏页检查,仍可被 veto */
shadow = workingset_eviction(folio, target_memcg);
}
trace_android_vh_clear_reclaimed_folio(folio, reclaimed);
__filemap_remove_folio(folio, shadow);
整条流程走完、page cache 即将摘除的瞬间,厂商仍能把一个文件页救回来(goto cannot_free)。配合关口 2 的 page_should_be_protected,Android 给 OEM 提供了"指定文件页绝不回收"的完整能力(典型用途:保护前台应用正在用的资源)。
2.4 关口 5(脏文件页)的系统级调控
脏文件页本身的主线机制(标记等待、flusher 洗白)Android 没改,改的是上游的洗白速度——让脏文件页更快变成关口 6 那种"零成本干净页"(init.rc:1092-1095,低内存设备):
on boot && property:ro.config.low_ram=true
write /proc/sys/vm/dirty_expire_centisecs 200 # 默认 3000(30s)→ 2s
write /proc/sys/vm/dirty_background_ratio 5 # 默认 10 → 5
配合 2.1(c) 的 may_writepage vendor 控制(可整体关掉 pageout 兜底),构成 Android 对"脏文件页"这一流程分支的完整态度:不阻塞回收路径,靠加快后台洗白保证候选池里干净页的比例。
2.5 阶段⑤ 反馈回路:基本不动,shmem 例外
mm/workingset.c与主线同源(diff 仅版本基线差异)——refault distance 机制、shadow 编码、WORKINGSET_*统计原样保留;- 唯一与文件页流程相关的系统性差异在 shmem:shmem 文件页逐出走 swap,而 Android 的 swap 恒为 zram(
gki_defconfig:331CONFIG_ZRAM=m),设备侧swappiness 60(init.gs101.rc:754)。即"shmem 脏页写出去"这一步从闪存 I/O 变成了内存压缩——pageout()对 shmem 的写出口在 Android 上天然指向 zram。
3. 对照总表
| 流程阶段 | 主线行为 | Android 改动 | 源码锚点 |
|---|---|---|---|
| ① 扫描平衡 | cache_trim_mode 硬编码偏向文件页;file_is_tiny 防抖逃生 | set_balance_anon_file_reclaim 可关偏向;tune_swappiness;modify_scan_control 可置 file_is_tiny/关 may_writepage;watermark_boost_factor=0 |
android15-6.6:3229/3198/7062;init.rc:439 |
| ② 选取隔离 | 传统双链 / MGLRU 世代+tier | MGLRU 默认启用+远程灰度;mm_isolate_priv_lru 可整体接管;降级路径可保护/强制降级(page_should_be_protected/page_referenced_check_bypass);mglru_should_abort_scan |
gki_defconfig:134;vmscan:2412/2843/2852/5571 |
| ③-1 引用检查 | folio_check_references 四种判决 | hook 可伪造任意判决;trylock 失败显式 KEEP;可整体跳过(bypass) | vmscan:1584/1587/1591/2852 |
| ③-2 writeback/脏页 | 脏文件页绝不主动写(标记等 flusher);pageout 仅 shmem/anon | 机制不变;上游加快洗白(dirty_expire 2s、bg_ratio 5);may_writepage 可关 | vmscan:1370(main)/2084(6.6);init.rc:1093 |
| ③-3 unmap | try_to_unmap,失败 activate | trylock_set/clear 标记跟踪;失败页批处理 hook | vmscan:2062/2715 |
| ④ 摘除释放 | __remove_mapping 留 shadow → free | keep_reclaimed_folio 最终否决点(cannot_free);clear_reclaimed_folio 通知 |
vmscan:1502/1507 |
| ⑤ 反馈回路 | refault distance → activate + 成本回流 | 代码同源不动;shmem 逐出指向 zram(swappiness 60) | workingset.c 全文;init.gs101.rc:754 |
4. 结论
主线 Linux 的文件页回收是一个"成本感知的缓存裁剪器":五阶段闭环里,判断热(引用检查、tier)、标记脏(PG_reclaim 等 flusher)、留证据(shadow)、听回声(refault distance 修正扫描平衡),脏文件页绝不主动写盘。
Android 的改动全部嵌在流程节点上,而不是替换流程,可归为三类:
- 改决策(阶段①):cache_trim 偏向变可选(zram 下换 anon 可能更便宜)、swappiness/scan_control 可被厂商逐次回收动态覆盖、关 watermark boost;
- 开插口(阶段②③):从"整条 LRU 隔离逻辑可接管"到"单页判决可伪造"、"page cache 摘除最后一刻可否决"——OEM 能精确控制哪些文件页被回收,这是主线完全不具备的能力;
- 保底不变(阶段④⑤):shadow/refault 反馈回路原样保留(同源代码),文件页回收质量的根基仍由主线算法保证。
一句话:Android 拿走了主线的文件页回收流水线,在"选多少"(阶段①)和"选哪个"(阶段②③)两处装上了厂商旋钮,而"怎么判断冷热、怎么防抖"的底层机制(workingset/MGLRU tier)原封不动。