页回收:kswapd 和 direct reclaim
上一篇讲了内核如何用 LRU 近似和 workingset 检测来判断"谁冷谁热"。判断完之后,实际把页面释放出来的工作由回收子系统完成。回收不是一个单点事件,而是围绕水位线、后台线程和分配路径形成的一套压力响应机制。
核心问题可以压成一句话:
页回收不是内存耗尽后的单点动作,而是围绕水位线、后台线程和分配路径形成的一套压力响应机制。
问题从哪里来
内存分配在 Linux 里几乎无处不在:用户态 malloc 背后的匿名页、page cache 的文件页、内核自己的 slab 对象。每次分配都从 buddy allocator 拿物理页。物理页是有限的。
如果等到完全分配不出页面再回收,分配方会被阻塞很长时间——回收可能涉及写脏页、等待 I/O、遍历反向映射。这种"等到没有了才动"的策略延迟不可控。
Linux 的做法是提前开始。内核设定一组水位线(watermark),在不同压力级别触发不同强度的回收。大部分情况下由后台线程 kswapd 在压力升起时提前回收;只有来不及时才在分配路径上直接回收(direct reclaim)。
水位线模型
每个 zone 有三条基本水位线,官方文档给出明确定义:
| 水位线 | 触发行为 |
|---|---|
| high | 空闲页高于此值时,kswapd 停止回收(zone 处于平衡状态) |
| low | 空闲页低于此值时,kswapd 被唤醒 |
| min | 空闲页低于此值时,分配路径可能触发 direct reclaim |
文档原文:
When free pages are below the low watermark, kswapd is woken up.
When above high watermark, kswapd stops reclaiming (a zone is balanced).
When free pages fall below min watermark, an allocation may trigger direct reclaim.
min 水位线由 vm.min_free_kbytes 控制。其余水位线按 vm.watermark_scale_factor 在 min 基础上拉开间距。间距越大,kswapd 的提前量越大,direct reclaim 的概率越低,但"闲置"的保留内存越多。
Linux 5.0+ 引入了 WMARK_BOOST 机制:当系统检测到碎片化程度升高(外部碎片事件频繁)时,临时抬高水位线,让 kswapd 更早开始回收。这样 compaction 有更多空闲页可用于合并高阶块。boost 是暂态的——碎片缓解后水位线回落。
1 | |
这三条线把系统状态切成三个区间:
- high 以上:正常分配,无回收活动。
- low 到 high 之间:kswapd 后台回收,分配不受阻塞。
- min 以下:分配路径直接参与回收,分配方被阻塞直到回收出足够页面。
kswapd:后台回收
kswapd 是 per-node 的内核线程。官方文档:
Per-node instance of kswapd kernel thread.
它的生命周期很简单:空闲页低于 low 时被唤醒,回收页面直到空闲页回到 high 以上,然后睡眠。
kswapd 的工作路径大致是:
- 被唤醒后调用
balance_pgdat()。 balance_pgdat()遍历当前 node 的 zone,找到需要回收的最高 zone。- 对每个需要回收的 zone 调用
shrink_node()→shrink_lruvec()。 shrink_lruvec()按比例扫描 inactive 链表,尝试回收足够的页面。- 回收够了或所有 zone 都回到 high 以上,回到睡眠。
kswapd 回收的好处是:分配路径不需要等待。用户态 malloc → page fault → 分配物理页,这条路径只需要检查是否有空闲页可拿。如果 kswapd 提前把空闲页准备好了,分配延迟极低。
观察 kswapd 活动:
1 | |
direct reclaim:分配路径上的回收
当空闲页低于 min 水位线,kswapd 可能来不及补充。此时分配请求本身进入回收路径——这就是 direct reclaim。
direct reclaim 对延迟的影响是直接的:分配方(可能是任何进程)被阻塞在 try_to_free_pages() 里,直到回收出足够页面为止。如果回收需要写脏页(等 I/O 完成),分配方的延迟可能达到毫秒甚至百毫秒级。
/proc/vmstat 中 allocstall 计数器记录 direct reclaim 发生的次数。这个值持续增长意味着系统长期处于内存压力下,kswapd 跟不上分配速度。
1 | |
kswapd vs direct reclaim 对比
| 维度 | kswapd | direct reclaim |
|---|---|---|
| 触发条件 | 空闲页 < low | 空闲页 < min 且分配发生 |
| 执行上下文 | 专用内核线程 | 分配方进程自身 |
| 对用户延迟影响 | 无(后台) | 直接阻塞分配方 |
| 停止条件 | 空闲页 ≥ high | 回收出足够当次分配的页面 |
| 计数器 | pgscan_kswapd / pgsteal_kswapd |
pgscan_direct / pgsteal_direct / allocstall |
理想状态是 kswapd 能维持空闲页在 low 以上,direct reclaim 极少发生。如果 allocstall 持续增长,意味着需要调整水位线参数或增加物理内存。
回收一页的完整路径
从 shrink_lruvec() 开始,回收一个页面的典型步骤:
- 从 inactive 链表尾部取出候选 folio。
- 尝试获取 folio 的锁。锁不到就跳过。
- 检查 folio 状态:
- 如果正在 writeback:跳过(等 I/O 不划算,除非压力极大)。
- 如果被 mlock 锁定:移到 unevictable 链表,跳过。
- 如果 referenced(被访问过):根据策略可能放回 active 或给一次机会。
- 如果 folio 是脏的:
- 文件页:触发 writeback(将 folio 提交给文件系统写回磁盘)。写回完成后才能回收。
- 匿名页:必须写到 swap。如果没有配置 swap 或 swap 满了,不可回收。
- 使用反向映射(上一篇讲的
try_to_unmap)撤销所有指向该 folio 的 PTE。 - 如果 folio 仍在 page cache 中,从
address_space的 xarray 中移除。 - 释放 folio 回 buddy allocator。
这条路径上任何一步失败都可能导致"扫描了但没回收成功"——这就是 pgscan 与 pgsteal 差值的来源。
sc->priority:扫描范围的指数退让
回收路径由 struct scan_control 控制。其中 sc->priority 决定每轮扫描的范围:扫描页数 = zone 总页数 >> priority。priority 从 12 开始(扫描 1/4096 的页面),每次回收循环如果未能释放足够页面就减 1,扫描范围翻倍。priority 降到 0 时扫描整个 zone。
这解释了一个常见的监控现象:/proc/vmstat 中的 pgscan 计数器可能突然暴增——不是"页面突然变多",而是 priority 降低后每轮扫描量指数增长。如果同时看到 pgsteal 没有同比增长,说明大量页面被扫描但无法回收(可能全是脏页或 mlock 页)。
compaction:回收与碎片整理的交互
回收释放的页面回到 buddy allocator,但分散释放的 order-0 页面不能直接满足高阶分配(如 THP 需要 order-9)。内核使用 compaction 来合并碎片:
- migrate scanner 从 zone 低地址向上扫描,找可移动的页面。
- free scanner 从 zone 高地址向下扫描,找空闲位置。
- 两者相遇时,通过把占用页迁移到高地址空闲位置,在低地址腾出连续空间。
compaction 与 reclaim 的关系:当高阶分配失败时,内核先尝试 compaction(compact_result);如果 compaction 也因为没有足够可移动页面而失败,才回退到回收。回收释放更多 order-0 页面后,compaction 再次尝试就有更多素材。这种"reclaim → compact → retry alloc"的循环是 THP fault-time 延迟尖刺的主要来源。
模式提炼:压力梯度决定响应级别
1 | |
| 压力级别 | 响应 | 延迟影响 |
|---|---|---|
| 无压力(> high) | 不回收 | 零 |
| 轻度压力(low ~ high) | kswapd 后台回收 | 无(对用户透明) |
| 中度压力(min ~ low) | kswapd 加速回收 | 轻微(如果回收赶不上分配) |
| 重度压力(< min) | direct reclaim | 明显(分配方阻塞) |
| 极端压力(回收失败) | OOM killer | 致命(进程被杀) |
这种"分级响应"与 TCP 拥塞控制(慢启动/拥塞避免/快速重传)结构相似:轻度压力下优化吞吐,重度压力下保护延迟,极端压力下保护存活。
一个最小实验:观察水位线与回收活动
这个实验通过逐步分配内存来制造压力,观察 kswapd 活动和 direct reclaim 的出现。
1 | |
编译与运行(建议在内存受限环境或 cgroup 中):
1 | |
同时在另一个终端观察 kswapd:
1 | |
完成实验后清理:
1 | |
读取实验结果
在内存受限环境(如 200MB cgroup)下预期看到的阶段变化:
前两到三次分配(128-192MB):pgscan_kswapd 和 pgsteal_kswapd 开始增长,说明 kswapd 被唤醒。系统尝试回收文件页(page cache 中的内容)和可能的匿名页(如果有 swap)。此时分配仍然成功,用户感知不到延迟。
中间阶段:pgscan_kswapd 增速加快,可能出现 allocstall 非零——direct reclaim 开始发生。分配方的 memset 变慢,因为每次 page fault 可能要等回收完成。
后期(接近 200MB 限额):如果 cgroup 内没有足够可回收页面且没有 swap,分配可能触发 cgroup OOM。allocstall 快速增长。
关键观察:
pgscan_kswapd先于pgscan_direct出现:后台回收先启动,direct reclaim 只在压力加剧后出现。pgsteal<pgscan的差值反映无法回收的页面(被锁定、正在 writeback、被引用)。- 给 kswapd 留 500ms 反应时间后,如果下一次快照
allocstall没有增长,说明 kswapd 跟上了分配速度。
模式提炼:后台维护先于前台阻塞
1 | |
这个模式在系统设计中是通用的:GC 的并发标记/增量回收先于 stop-the-world、数据库的 background checkpoint 先于 log-full stall、文件系统的 journal commit 先于 sync write 阻塞。核心思想是把不可避免的维护工作尽量推到后台,只有后台跟不上时才让前台承受延迟。
文件页 vs 匿名页的回收成本
回收路径对文件页和匿名页的处理不同:
| 维度 | 文件页 | 匿名页 |
|---|---|---|
| 干净时 | 直接丢弃(数据在磁盘) | 不可回收(无 backing) |
| 脏时 | 写回文件后丢弃 | 写到 swap 后丢弃 |
| 写回开销 | 取决于文件系统和磁盘 | swap 通常比文件 I/O 更贵 |
| 无 swap 时 | 可回收(干净)或触发 writeback | 完全不可回收 |
| 再次需要时 | 从文件重新读取 | 从 swap 读回 |
这解释了为什么没有 swap 的系统在内存压力下行为会突变:文件页回收完之后,匿名页无处可去,直接到 OOM。配置 swap 不是为了"让系统变慢",而是给匿名页一条回收通道,让回收梯度不在文件页耗尽后断裂。
核心结构:scan_control
struct scan_control 驱动每一轮回收(mm/vmscan.c,内部结构,精简):
1 | |
priority 从 12 开始:扫描量 = zone 页数 >> priority。每次失败 priority 减 1,扫描翻倍。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/vmscan.c |
balance_pgdat()、try_to_free_pages()、shrink_node()、shrink_lruvec() |
mm/vmscan.c |
shrink_folio_list():单个 folio 的回收判定逻辑 |
mm/page_alloc.c |
分配路径如何检查水位线、决定是否进入 direct reclaim |
include/linux/mmzone.h |
struct zone 的 _watermark 字段定义 |
mm/rmap.c |
try_to_unmap():回收前撤销映射 |
读 shrink_folio_list() 时可以带着一个问题:哪些条件会让一个 folio"被扫描但不被回收"?把这些条件列出来,就能理解 pgscan/pgsteal 差值的全部来源。
研究生迁移表
| Linux 概念 | 一般系统设计 |
|---|---|
| 水位线(min/low/high) | 分级告警阈值(warning/critical/fatal) |
| kswapd | 后台维护线程(GC thread、checkpoint thread) |
| direct reclaim | 前台阻塞式清理(stop-the-world GC、sync flush) |
allocstall |
前台阻塞事件计数(GC pause counter) |
| swap writeback | 溢出到慢速层(tiered storage spill) |
| OOM killer | 最后手段的资源释放(circuit breaker、pod eviction) |
这套"水位线 + 后台线程 + 前台 fallback + 最后手段"的架构在容器调度的资源回收中有直接对应。
常见误解
第一个误解是把回收等同于 OOM。回收是常态操作,几乎所有运行中的 Linux 系统都有持续的回收活动。OOM 只在回收彻底失败后才发生。
第二个误解是认为 kswapd 活动意味着系统有问题。kswapd 的正常工作正是为了避免 direct reclaim。只有 allocstall 持续增长才说明 kswapd 跟不上。
第三个误解是认为增大 vm.min_free_kbytes 总是好的。增大 min 会增加保留的空闲内存,减少 direct reclaim 概率,但同时减少了可用内存总量。在内存本就紧张的机器上反而可能加剧压力。
第四个误解是把 direct reclaim 视为 bug。它是设计的一部分——当瞬时分配速率超过 kswapd 的补充速率时,让分配方自己参与回收是合理的降级策略。问题只在于频率:偶尔的 direct reclaim 正常,持续的 direct reclaim 需要排查。
第五个误解是认为没有 swap 就不会有回收。没有 swap 时匿名页不可回收,但文件页(干净的 page cache)仍然可以回收。内核持续通过回收文件页来维持空闲内存。
练习
第一,在一台 Linux 机器上查看当前水位线:cat /proc/zoneinfo | grep -A3 'pages free',记录每个 zone 的 min/low/high 值与当前 free 值。计算当前系统处于哪个压力区间。
第二,按文中实验运行内存压力程序(使用 cgroup 限制到 200MB),记录每个阶段的 pgscan_kswapd、pgscan_direct、allocstall 变化。画一张时间轴,标出 kswapd 唤醒点和 direct reclaim 出现点。
第三,在一台有 swap 和一台没有 swap 的环境中分别运行同样的内存压力测试。比较两者到达 OOM 的速度差异。解释为什么有 swap 时系统可以承受更大的匿名页分配量。
第四,阅读 mm/vmscan.c 中 shrink_folio_list() 的实现,列出所有导致 folio 被跳过(不回收)的条件。按"可恢复"和"永久性"分类这些条件。
第五,运行 sar -B 1 60(或等价工具)观察一个正常运行的 Linux 服务器 60 秒内的 pgscank(kswapd scan)和 pgscand(direct scan)比例。在正常负载下,这个比例应该是多少?为什么?
系列导航
- 上一篇:LRU 和 workingset:内核如何近似"最近使用"
- 本文:页回收:kswapd 和 direct reclaim
- 下一篇:Swap:换出去的页怎样回来
