审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
物理页不够时,内核变不出内存——只负责决定拆掉谁的映射。
2026-09-26 · Page Reclaim, kswapd and LRU

第 5 关 · 页面回收:kswapd、LRU 与 swap

定位

这一关回答内存管理最残酷的问题:物理页不够用时,内核怎么办。答案比直觉更冷:内核变不出内存——buddy 里没有空闲页了就是没有了,回收的全部工作是"决定拆掉谁的映射",把页还给 buddy。三条水位线是整套机制的油门和刹车:min 是分配器的红线,跌破就调用者自己动手同步回收;low 是唤醒 kswapd 的线;high 是 kswapd 收工的线。

这一关的三个机制支点:watermark 三水位(enum zone_watermarks,include/linux/mmzone.h)→ kswapd 异步回收 vs 直接回收;LRU 四链表(active/inactive × anon/file)+ shrink 路径;Dirty/Accessed 硬件位是回收算法的眼睛。M04 的 PTE 里那两个"由 CPU 置位"的位,在这一关第一次被消费;M03 的 buddy 是回收页的归宿;M01 的 task_struct 是 OOM killer 打分的对象。

核心机制深讲

5.1 三水位:min、low、high——油门与刹车

每个内存 zone 有三条水位线,定义在 enum zone_watermarks { WMARK_MIN, WMARK_LOW, WMARK_HIGH, ... }(include/linux/mmzone.h:808)。语义分工极其明确:min 是分配快路径的红线——zone_watermark_ok()(mm/page_alloc.c:3676)检查的就是它;low 是唤醒 kswapd 的线;high 是 kswapd 收工的线——pgdat_balanced() 用 high_wmark_pages() 判定"够了,可以睡了"(mm/vmscan.c:6963)。

水位的数值不是拍脑袋的。setup_per_zone_wmarks()(mm/page_alloc.c)里:WMARK_LOW = min + tmp,WMARK_HIGH = low + tmp,其中 tmp 按 zone 大小比例算(watermark_scale_factor,默认万分之十)。源码注释把设计意图写得明明白白:"The WMARK_HIGH-WMARK_LOW and (WMARK_LOW-WMARK_MIN) deltas control async page reclaim"——low/high 与 min 的距离,控制的就是 kswapd 每次被叫醒要干多少活。

注意 high 和 low 之间的滞回(hysteresis):kswapd 不是干回 low 就停,而是干到 high 才睡。为什么?下一节回答。

5.2 kswapd:每节点的常驻回收工

kswapd()(mm/vmscan.c:7492)是每个内存节点一个的内核线程,上来就给自己挂 PF_KSWAPD|PF_MEMALLOC——"内存分配者"身份,意思是:我回收时自己需要内存也不会被卡住(否则"为了腾内存而申请内存"会死锁)。主循环三步:kswapd_try_to_sleep() 睡 → 被 wakeup_kswapd()(mm/vmscan.c:7573)叫醒 → balance_pgdat() 干活 → 干到 pgdat_balanced() 为止。

唤醒点在分配慢路径(mm/page_alloc.c:4522):分配者在继续尝试之前,先把各节点的 kswapd 叫醒——"你先去干,我继续试"。异步的含义是:kswapd 干活时,分配者不等它。min 水位的存在意义,就是保证"在 kswapd 干完之前,分配器手里还有子弹"——min 到 low 之间的页,是留给异步回收的时间窗口。

5.3 直接回收:跌破 min 时,自己动手

min 水位一破,温柔的时代就结束了。__alloc_pages_direct_reclaim()(mm/page_alloc.c:4464):调用者亲自上阵,try_to_free_pages() → shrink_lruvec()——同步、慢、而且占用的是申请内存那个进程自己的时间片。但别无选择:min 之下再等 kswapd,就是等分配失败。

直接回收的进程会被 throttle(按优先级退避):如果所有缺页的进程同时涌入回收,系统会活锁——大家都在拆映射,没人推进度。min 水位的另一层含义是"异步回收已经来不及了"的宣告:kswapd 是常规军,直接回收是全民皆兵,而全民皆兵本身是需要被限流的。

5.4 LRU 四链表与 shrink 路径

回收的"拆谁"由四张链表决定:enum lru_list(include/linux/mmzone.h:389)——LRU_INACTIVE_ANON、LRU_ACTIVE_ANON、LRU_INACTIVE_FILE、LRU_ACTIVE_FILE(另有 LRU_UNEVICTABLE 给锁内存页)。四象限 = anon/file × active/inactive。

为什么分 anon/file?因为它们的"家"不一样:file 页有后备存储——干净的 file 页直接丢弃就行,零代价;anon 页在内存之外没有家,必须先有地方去(swap)才能拆。is_file_lru() 就是这条分界线的代码形态。

回收路径:shrink_lruvec()(mm/vmscan.c:5969)→ shrink_list()(2212)→ shrink_folio_list() 拆 LRU 页;同一趟还会调 shrink_slab()(5119)——M03 的 slab 缓存在这里第一次"被拆",dentry、inode 这些内核对象缓存也是回收的对象。inactive_is_low()(2254)维持 active/inactive 的比例:inactive 太少就从 active 降级——这是"二次机会"的链表版本。

5.5 refault 与 Dirty/Accessed:回收算法的眼睛

回收最大的误判是抖动(thrash):工作集比内存大时,刚回收的页马上又被缺页读回来——回收做了无用功。mm/workingset.c 做 refault 距离检测:页被回收时记下逐出时间戳,短时间内 refault 回来,说明它其实很热——激活它(放到 active 链表),而不是下次再回收。回收算法认错了人,workingset 负责纠正。

另一双眼睛是硬件给的。x86 PTE 第 5、6 位:_PAGE_BIT_ACCESSED 5 /* was accessed (raised by CPU) */、_PAGE_BIT_DIRTY 6 /* was written to (raised by CPU) */(arch/x86/include/asm/pgtable_types.h:15-16)——"raised by CPU",内核只读不写。shrink 碰到 A 位置位的页:清掉 A 位、放回链表——"下次再来时还没被访问过,就真回收",这就是第二次机会。D 位决定"怎么拆":干净页直接丢,脏页必须先回写(file)或 swapout(anon)。M04 埋的两个位,在这里第一次被消费——这就是"链回"。

5.6 swap、zswap 与 OOM killer:防线的末端

anon 页的"家"是 swap:swap_writepage 把页写出,PTE 换成 swap entry——映射拆了,内容有处可去。zswap(mm/zswap.c)是 swap 前面的压缩缓存——"家门口的储物间":先压缩了放内存里,实在放不下再写真正的 swap。注意它是"前面"不是"替代":压缩比不确定,内存本身也不够时,仍需要真正的后备存储。

最后的手段是 out_of_memory()(mm/oom_kill.c:1103)。回收是"拆映射",OOM 是"拆进程":连直接回收都搞不到页、且不能再等时,内核按 oom_badness 打分,选一个 task_struct 杀掉——M01 的 task_struct 在这里是"被杀对象"。OOM 不是回收失败,是回收的语义走到了尽头:映射已经无处可拆,只能拆进程。

为什么这样设计

为什么 kswapd 要干到 high 水位才睡,而不是回到 low 就停?

因为 low 是"叫醒线",停在 low 等于刚睡就被叫醒。high-low 之间的距离是滞回带:kswapd 多干的这一段,是买"下次分配跌破 low 之前的时间"。没有滞回,kswapd 会陷入"醒来干一点又睡"的高频抖动——上下文切换的开销比多回收几页贵得多。水位设计的本质是:用多干的活,换少醒的次数。

为什么 LRU 要 active/inactive 双链表,而不是一条链表从头回收?

单链表下,刚读进来的页和一年没碰的页混在一起,一次顺序扫描就可能全回收——M01 式的"扫一遍"灾难。双链表是"新页要先证明自己"的两级评审:新页先进 inactive,二次访问(或 refault)才转正 active。inactive 链表就是"观察期",active 链表就是"转正名单"。

为什么 anon 页回收必须走 swap,而干净的 file 页可以直接丢?

因为 file 页的内容在磁盘上有另一个副本——干净意味着"和磁盘一致",丢弃零代价,下次缺页再读回来就是。anon 页的内容只存在于内存里,没有 swap 就没有"别处"。zswap 只是把"别处"从磁盘换成压缩内存,解决不了"必须有别处"这个问题——所以 zswap 永远是 swap 的前面,不是替代。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

Q1. enum zone_watermarks 的三个水位是什么?min、low、high 各触发什么动作?

WMARK_MIN、WMARK_LOW、WMARK_HIGH(include/linux/mmzone.h:808)。min:分配快路径的红线,zone_watermark_ok() 检查它,跌破则进入慢路径、调用者直接回收;low:唤醒 kswapd 的线;high:kswapd 干到这里收工(pgdat_balanced() 用 high_wmark_pages() 判定)。

Q2. 为什么 kswapd 要干到 high 水位才睡,而不是回到 low 就停?

滞回(hysteresis)。low 是叫醒线,停在 low 等于刚睡就被下一次分配叫醒——"醒来干一点又睡"的高频抖动,上下文切换开销比多回收几页贵。high-low 的距离买的是"下次跌破 low 之前的时间"。水位设计的本质:用多干的活,换少醒的次数。

Q3. 脏 file 页、干净 file 页、anon 页,回收代价从低到高排序并解释。

干净 file 页 < 脏 file 页 < anon 页。干净 file 页:和磁盘一致,直接丢弃,零代价;脏 file 页:D 位置位,必须先回写到后备存储才能拆;anon 页:内存之外没有家,必须先 swap_writepage 写到 swap(或进 zswap)才有处可去。代价排序的本质是"内容有没有别处可去"。

Q4. wakeup_kswapd 在分配路径的什么位置被调用?为什么分配者不等 kswapd 干完?

在 __alloc_pages 慢路径(mm/page_alloc.c:4522):分配者在继续尝试之前,先把各节点的 kswapd 叫醒。不等的原因是异步设计:kswapd 是常规军,min→low 之间的水位差就是留给它的时间窗口——min 水位保证"在 kswapd 干完之前分配器还有子弹"。等它干完再分配,等于把异步退化成同步。

Q5. Accessed 位由谁置位?shrink 遇到 A 位置位的页怎么做?"第二次机会"是什么意思?

由 CPU 硬件置位(_PAGE_BIT_ACCESSED 5 /* was accessed (raised by CPU) */,arch/x86/include/asm/pgtable_types.h:15)——内核只读不写。shrink 遇到 A 位置位的页:清掉 A 位、放回链表,不回收。"第二次机会"就是:下次 shrink 再来时,如果 A 位还没被置位,说明这期间真没人访问——这时才回收。

Q6. refault 频繁说明什么?workingset 如何纠正回收算法的误判?

说明工作集大于内存——系统在抖动(thrash):刚回收的页马上又被缺页读回来,回收做了无用功。mm/workingset.c 做 refault 距离检测:页被逐出时记时间戳,短时间内 refault 回来就激活它(放进 active 链表)而不是下次再回收。回收算法按"冷"拆了它,workingset 用 refault 证据纠正:"你认错了,它其实很热"。

Q7. 直接回收的调用者为什么会被 throttle?如果不限流会发生什么?

因为跌破 min 时,所有缺页的进程会同时涌入直接回收——大家都在拆映射、写回、swapout,没人推进度,系统活锁(livelock)。throttle 按优先级让直接回收的进程退避,把回收压力导回 kswapd。全民皆兵本身是需要被限流的——min 水位宣告"常规军来不及了",但不能让全民皆兵把系统拖死。

Q8. 从 __alloc_pages 慢路径出发,写出完整的回收调用链,并标出每个函数所在的文件。

__alloc_pages 慢路径(mm/page_alloc.c)→ wakeup_kswapd()(mm/vmscan.c:7573,叫醒各节点)→ kswapd 线程 kswapd()(mm/vmscan.c:7492)→ balance_pgdat() → shrink_lruvec()(mm/vmscan.c:5969)→ shrink_list()(2212)→ shrink_folio_list()(拆 LRU 页)/ shrink_slab()(拆 slab 缓存)。跌破 min 时调用者走另一条:__alloc_pages_direct_reclaim()(mm/page_alloc.c:4464)→ try_to_free_pages() → 同一套 shrink_lruvec()。

Q9. 为什么 zswap 是"swap 前面的压缩缓存",而不是"swap 的替代品"?

因为压缩比不确定:zswap(mm/zswap.c)把 anon 页压缩了放内存里,压缩完放不下时仍需要真正的 swap 做后备。它解决的是"swap 太慢",解决不了"anon 页必须有别处可去"——内存本身也不够时,压缩只是推迟问题,不是消灭问题。zswap 永远需要一个真正的"家"在身后,所以它是 swap 的前面,不是替代。

Q10. 设计题:一台 256GB 内存的机器,min_free_kbytes 设得太小和太大,分别有什么后果?结合三水位公式论证。

太小:min 水位过低,突发大内存申请直接跌破 min——全员进入 __alloc_pages_direct_reclaim(),申请进程自己当苦力,延迟飙升;极端时 kswapd 来不及,直接 OOM。太大:GB 级内存被水位"预留"而闲置(256GB 的 1% 就是 2.5GB 常驻不用),且 kswapd 被过早唤醒做无用功。水位公式是 low = min + tmp、high = low + tmp(tmp 按 zone 比例):min 决定"什么时候进入战时状态",tmp 决定"kswapd 每次干多少"。合理值看两个量:突发分配的速率(min 要 cover kswapd 干完一波的时间)和常驻浪费的上限。

延伸阅读

free pages
  │
high ─────────────────────  kswapd 干到这里收工睡觉
  │   ▲ hysteresis 缓冲带
  │   │ (kswapd 的工作区间:多干活,少醒来)
low  ─────────────────────  跌破 → wakeup_kswapd()
  │   ▲ min→low 是留给异步回收的时间窗口
min  ─────────────────────  跌破 → 直接回收(调用者自己动手)
  ▼
 分配失败 / OOM

三水位一句话:min 是红线,low 是闹钟,high 是收工线。low/high 与 min 的距离(tmp,按 zone 大小比例)控制 kswapd 每次干多少活——注释原话:"The WMARK_HIGH-WMARK_LOW and (WMARK_LOW-WMARK_MIN) deltas control async page reclaim"。

__alloc_pages 慢路径(mm/page_alloc.c)
  │
  ├─ free < low ─→ wakeup_kswapd() ──→ kswapd 线程(每节点一个)
  │                                          │
  │                                     balance_pgdat()
  │                                          │
  │                                     shrink_lruvec()
  │                                      ├─ shrink_list() → shrink_folio_list()
  │                                      └─ shrink_slab()(拆 dentry/inode 缓存)
  │
  └─ free < min ─→ __alloc_pages_direct_reclaim()
                        └─ try_to_free_pages() → 同一套 shrink_lruvec()
                           (调用者自己执行,又慢又会被 throttle)

两条路走的是同一套 shrink 代码,区别只在"谁来执行":kswapd 是异步常规军,直接回收是同步全民皆兵。

              inactive                 active
           ┌──────────────┐      ┌──────────────┐
  anon     │INACTIVE_ANON │ ───▶ │ ACTIVE_ANON  │ ──▶ swapout(没家,先找家)
           └──────────────┘      └──────────────┘
  file     │INACTIVE_FILE │ ───▶ │ ACTIVE_FILE  │ ──▶ 回写 / 直接丢弃
           └──────────────┘      └──────────────┘
                  ▲                     ▲
            新页先到这里          二次访问 / refault 才转正

四象限 = anon/file × active/inactive。inactive 是"观察期",active 是"转正名单"——新页要先证明自己热两次,才配进 active。refault 的页直接转正:workingset 用"回来得快"纠正回收的误判。

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。