引言
当系统物理内存从充裕走向紧张,Linux 内核如何在后台悄悄回收"沉睡"的页面?当匿名页面被换出到磁盘,应用程序为何感受不到明显的卡顿?本文将深入 Linux 内核内存子系统的"另一半"——页回收(Page Reclamation)与 Swap 机制,从 LRU 链表算法到 kswapd 守护进程,从传统 swap 分区到 zswap 压缩缓存,逐层揭开内存压力下内核自动调度与消化的完整内幕。
本文是「Linux 内核底层深度实战」系列的姊妹篇。如果 Buddy 分配器与 Slab 机制深度剖析 解答了"内存从哪里来",那么本文将回答 "内存满了怎么办"。
一、为什么需要页回收?
Linux 物理内存管理遵循一个核心原则:物理内存不应被浪费。文件缓存(Page Cache)、匿名映射(Anonymous Mapping)、内核数据结构都在消耗物理页框。当空闲页面低于阈值时,内核必须腾出页面给更紧急的分配请求。
页回收的核心触发机制基于 水位标记(Watermark):
// mmzone.h
enum zone_watermarks {
WMARK_MIN, // 紧急水位,低于此触发直接回收
WMARK_LOW, // 触发 kswapd 后台回收
WMARK_HIGH, // kswapd 认为"够了"的阈值
};
// 触发型分配路径
alloc_pages()
→ __alloc_pages_nodemask()
→ get_page_from_freelist() // 快速路径:检查 free_list
→ __alloc_pages_slowpath() // 慢速路径:
→ __alloc_pages_direct_reclaim() // 直接回收
→ wakeup_kswapd() // 唤醒后台回收
→ __alloc_pages_direct_compact() // 内存碎片整理
二、LRU 算法:页面冷热分层的基石
2.1 双链表 LRU 设计
Linux 内核从 2.6 版本起采用 双链表 LRU(Active/Inactive) 设计,每个 NUMA 节点维护一类页面的 Active 和 Inactive 链表:
// mmzone.h
struct lruvec {
struct list_head lists[NR_LRU_LISTS]; // 5 种 LRU 链表
unsigned long // 各链表页面计数
struct pglist_data *pgdat; // 所属节点
};
// LRU 链表类型
enum lru_list {
LRU_INACTIVE_ANONYMOUS, // 非活跃匿名页(换出候选)
LRU_ACTIVE_ANONYMOUS, // 活跃匿名页
LRU_INACTIVE_FILE, // 非活跃文件页(回收候选)
LRU_ACTIVE_FILE, // 活跃文件页(锁定不可回收)
LRU_UNEVICTABLE, // 不可回收(mlock/驱动映射)
NR_LRU_LISTS
};
这种设计的精妙之处在于:不同类型页面的回收代价完全不同。文件页直接写回磁盘(甚至干净的缓存页直接丢弃),匿名页需要先写入 swap 空间才能释放。因此内核维护独立的匿名页和文件页 LRU 链表,按需调节回收比例。
2.2 页面在 LRU 间的升降级
页面在 Active 和 Inactive 链表之间的移动遵循一个简单规则:被访问过的页面晋升到 Active,长时间未被访问的页面降级到 Inactive。
// 核心函数调用链
mark_page_accessed()
→ page_referenced() // 检查硬件 Access/Dirty 位
→ activate_page() // Inactive → Active
deactivate_page() // Active → Inactive
→ page_evictable() // 检查是否可回收
// 硬件层面:x86 PTE_A (Accessed) 位
// MMU 自动置位,内核通过 page_referenced_one() 检查
// 每次检查清除 PTE_A,下次访问时再次置位
实际运作时:
- 第一次访问 page →
set_pte_at()写入 PTE(PTE_A=0) - CPU 自动置位 PTE_A(Accessed bit)
- 内核扫描 LRU 时调用
page_referenced(),检查并清除 PTE_A - 如果 PTE_A 为 0 → 降级到 Inactive(最近没被访问)
- 如果 PTE_A 为 1 → 已访问过,保持/晋升到 Active
2.3 最近是否访问的"双次机会"
为了避免单一位判断导致的误回收,Linux 采用 双次机会算法(Second Chance):页面需要连续两次扫描都未被访问才会真正被回收。PTE_A 被清除后在下次扫描前如果又被访问,则重新晋升到 Active 链表。
这种设计确保了:短暂空闲的页面不会立即被回收,给工作集频繁波动留下了缓冲空间。
三、kswapd:后台回收守护进程
3.1 唤醒与睡眠
每个 NUMA 节点运行一个 kswapd 守护进程(kswapd%d)。kswapd 在初始化时进入睡眠状态,只有在空闲页面低于 WMARK_LOW 时被唤醒:
// mm/vmscan.c
static int kswapd(void *p) {
pg_data_t *pgdat = (pg_data_t*)p;
while (!kthread_should_stop()) {
// 1. 检查是否空闲页低于 WMARK_LOW
if (pgdat_balanced(pgdat, order, classzone_idx))
goto balanced; // 不需要回收,直接睡眠
// 2. 尝试后台异步回收
// 使用 balance_pgdat() 核心函数
pgdat->kswapd_failures = 0;
balance_pgdat(pgdat, order, classzone_idx);
balanced:
// 3. 进入可中断睡眠,直到再次被唤醒
wait_event_interruptible(pgdat->kswapd_wait,
kswapd_should_run(pgdat));
}
}
3.2 balance_pgdat:平衡函数
balance_pgdat() 是 kswapd 的核心逻辑,它根据当前压力水平动态调整回收力度:
static unsigned long balance_pgdat(pg_data_t *pgdat,
int order,
int classzone_idx) {
// 确定回收优先级(优先级越高,扫描越激进)
// priority 12 → 宽松扫描(正常情况)
// priority 0 → 极度激进(接近 OOM)
for (priority = DEF_PRIORITY; priority >= 0; priority--) {
// 按比例扫描 Active/Inactive 链表
// 根据 swappiness 调节匿名/文件页回收比例
shrink_node(pgdat, &sc);
// 检查是否达到 WMARK_HIGH
// 如果满足则返回(回收结束)
}
}
优先级机制:priority 从 12 降到 0,每次迭代扫描更多页面、更激进地回收。优先级越低,越倾向于回收活跃页面,代价越大但效果越明显。
3.3 shrink_node:节点级回收
static void shrink_node(pg_data_t *pgdat,
struct scan_control *sc) {
// 1. 遍历该节点下所有内存控制组(memcg)
// 每个 memcg 内部有自己的 LRU 链表
mem_cgroup_iter();
shrink_memcg_cgroup();
// 2. 收缩 slab 缓存(dentry/inode 缓存)
shrink_slab(sc->nr_reclaimed, sc->gfp_mask,
pgdat->node_id);
// 3. 扫描 LRU 链表,将页面从 Inactive 尾部获取
shrink_lruvec();
}
四、直接回收:分配器的最后手段
当快速分配路径失败、kswapd 来不及回收时,分配请求者自身被迫参与回收——这就是 直接回收(Direct Reclaim)。直接回收会在分配线程上下文中同步执行,分配线程被阻塞直到回收完成。
// 直接回收流程
alloc_pages()
→ __alloc_pages_slowpath()
→ gfp_to_alloc_flags()
→ __alloc_pages_direct_reclaim()
→ try_to_free_pages()
→ throttle_direct_reclaim() // 限流防止过度阻塞
→ do_try_to_free_pages()
→ shrink_zonelist()
→ shrink_node() // 与 kswapd 共享核心逻辑
直接回收对性能的影响:
- 分配延迟抖动:原本微秒级操作变为毫秒级甚至秒级
- 陷入内存回收死循环:回收触发的 I/O 本身需要分配页面
- throttle 机制:内核通过
throttle_direct_reclaim()确保最多只有一部分请求进入直接回收
4.1 直接回收与 kswapd 的协作
两者共享相同的 shrink_node() 和页面选择逻辑,但关键区别在于上下文和限制:
| 维度 | kswapd (后台) | 直接回收 (同步) |
|---|---|---|
| 上下文 | 内核线程(可睡眠) | 用户/内核分配上下文(可能持有锁) |
| 触发条件 | 空闲 < WMARK_LOW | 快速路径分配失败 |
| 扫描范围 | 通常 < 1 个节点 | 全局 + order-aware |
| GFP 限制 | GFP_KERNEL | 遵循调用者的 GFP flags |
| 文件系统访问 | 可以自由执行 I/O | 可能受 GFP_NOFS/GFP_NOIO 约束 |
五、Swap 机制:匿名页的"逃生通道"
5.1 换出流程(Swap Out)
当 Inactive 匿名页被选中回收时,内核必须先将页面数据写到 swap 空间:
// mm/vmscan.c: shrink_page_list()
// 针对匿名页的换出路径
pageout() // 主换出函数
→ mapping->a_ops->writepage() // 文件页 → 写回磁盘
→ swap_writepage() // 匿名页 → 写入 swap
→ get_swap_bio() // 分配 swap slot
→ submit_bio() // 异步写入
// 写回完成后,页面从 LRU 移出
// 物理页面被释放回 buddy 系统
// PTE 内容被替换为 swap_entry_t
关键数据结构:PTE 中的 swap_entry_t 编码了页面在 swap cache 中的位置:
// swap_info_struct
struct swap_info_struct {
unsigned long *swap_map; // 引用计数映射
struct swap_cluster_info *cluster_info; // 簇信息
struct block_device *bdev; // 所在块设备
unsigned int swap_file_pages; // 总页面数
unsigned char swap_device; // 设备号
};
// swap_entry_t 编码格式:
// | type (5 bits) | offset (53 bits) [64-bit]
// swap slot index 文件内偏移页数
5.2 页面换入(Swap In)与缺页中断
当应用程序访问被换出的页面时触发 Major Page Fault:
// arch/x86/mm/fault.c: do_page_fault()
→ __do_page_fault()
→ handle_mm_fault()
→ handle_pte_fault()
→ do_swap_page()
→ lookup_swap_cache() // 先在 swap cache 查找
→ swapin_readahead() // 预读附近页面(减少 I/O 次数)
→ read_swap_cache_async()
→ alloc_page() // 分配新物理页
→ swap_readpage() // 从 swap 读取数据
// 重建 PTE,指向新物理页
set_pte_at(mm, addr, ptep, pte);
内核采用 Swapin Readahead(换入预读) 策略——每次换入一个页面时,预读它附近的 2^n 个页面。这种局部性优化减少了随机 I/O。
5.3 Swap Cache:避免重复换入换出
Linux 引入 Swap Cache 解决一个经典问题:页面换出、又被访问、又重新换出的颠簸。
// 换出路径:
// 1. 页面从 LRU 移除,加入 swap_cache_add()
// 2. 页面标记为 PG_swapcache
// 3. 实际 I/O 异步完成
//
// 如果 I/O 完成前页面又被访问:
// do_swap_page() → lookup_swap_cache() 命中
// → 无需执行磁盘 I/O,直接从 swap cache 返回页面
// 换入后:
// 页面保留在 swap cache 中直到:
// a) 页面被修改(PG_dirty set)
// b) slot 被重新分配给其他页面
Swap Cache 是 swap 子系统的"缓冲层",使得频繁换入换出的页面可以保持在内存中,避免无谓的磁盘 I/O。
六、swappiness:匿名与文件页回收的比例调优
vm.swappiness(取值 0-200)控制匿名页与文件页之间的回收偏好:
// mm/vmscan.c: get_scan_count()
/*
* swappiness 的含义:
* 0 = 几乎不回收匿名页,只回收文件页缓存
* 60 = 默认值,文件页与匿名页按 LRU 比例均等回收
* 100 = 平等对待匿名和文件页(仅按 LRU 频率)
* 200 = 尽可能回收匿名页(每次匿名页是文件页的两倍权重)
*/
anon_prio = swappiness;
file_prio = 200 - swappiness;
// 实际扫描量比例 = (LRU_list_size * priority_weight) / total_weight
不同场景下的推荐配置:
- 数据库服务器(大量文件缓存):
vm.swappiness=1,优先保留文件页缓存 - Java/大数据(大堆内存,随机访问):
vm.swappiness=10,让匿名页尽量驻留 - 桌面/开发机(混合负载):
vm.swappiness=60,默认平衡 - 容器节点:
vm.swappiness=0,强制内存控制组限额而非 swap
6.1 与内存控制组的交互
cgroup v2 中,memory.swap.max 可限制每个控制组的 swap 使用量。当 swappiness=0 时,内核仅在文件页完全不可回收时才回收匿名页(最后的手段)。
七、zswap:压缩缓存加速 Swap
zswap 是 Linux 3.11 引入的 压缩 swap 缓存:在页面被写入物理 swap 设备之前,先尝试在内存中压缩存储。
7.1 核心原理
// mm/zswap.c: zswap_frontswap_store()
/*
* zswap 工作流程:
*
* 1. 页面准备写入 swap 设备
* → zswap_frontswap_store() 或 zswap_writeback_ent()
*
* 2. 使用压缩引擎(默认 zstd/lzo/lz4)压缩页面
*
* 3. 通过 zsmalloc 分配器存储压缩后的数据
* zsmalloc 专为压缩数据优化,避免碎片
*
* 4. 如果压缩后能放入内存池:
* → 页面被"吸收"在原物理内存中
* → 不触发实际 swap I/O
* → 原物理页面被释放!
*
* 5. 如果池满了:
* → 驱逐最旧压缩帧到真实 swap 设备
* → 被称为 "writeback" 操作
*/
7.2 zswap 与 zram 的区别
| 维度 | zswap | zram |
|---|---|---|
| 本质 | swap 的压缩缓存层 | 独立的块设备(ramdisk) |
| 对内核透明 | 是(前端存储层) | 否(需要 mkswap/swapon) |
| 最大容量 | pool_percent % 物理内存 | 可配置(常为内存 50-100%) |
| 溢出处理 | 写入真正的 swap 设备 | LRU 直接驱除不写盘 |
| 压缩算法 | 可配置(zstd 默认) | 可配置 |
| 适用场景 | 有 swap 分区时加速 | 无 swap 分区时的替代方案 |
7.3 启用与调优
# 启用 zswap(内核参数)
zswap.enabled=1
zswap.compressor=zstd # 或 lz4/lzo
zswap.max_pool_percent=20 # 最多 20% 物理内存用于 zswap pool
zswap.zpool=zsmalloc # zsmalloc/zbud/z3fold
# 查看 zswap 状态
cat /sys/kernel/debug/zswap/stored_pages # 当前存储压缩页数
cat /sys/kernel/debug/zswap/pool_total_size # 池总字节数
cat /sys/kernel/debug/zswap/reject_compress_poor # 压缩率过高被拒绝次数
cat /sys/kernel/debug/zswap/written_back_pages # 写回真实 swap 的页数
zswap 对性能的提升典型值:将 swap 延迟从毫秒级(固态硬盘)降低到数百微秒级(内存压缩解压)。对于不适合放入压缩池的随机数据(如已经压缩的媒体文件),zswap 会自动让它们通过。
八、OOM Killer:最后防线
当所有回收手段耗尽,系统仍然内存超限时,内核触发 OOM Killer(Out-Of-Memory Killer) 强制选择一个进程终止以释放内存。
// mm/oom_kill.c
/*
* OOM 评分机制(oom_badness):
*
* 评分 = (进程内存占用 × oom_score_adj × 1000) / total_vm
*
* 权重因子:
* - 占用物理内存越多 → 分数越高
* - 运行时间越长 → 分数越低(保护长运行服务)
* - oom_score_adj = -1000 → 永远不会被选中(关键守护进程)
* - oom_score_adj = 0 → 默认行为
* - oom_score_adj = +1000 → 优先被选中
*/
// 保护关键进程
echo -1000 > /proc/<pid>/oom_score_adj # 免疫 OOM
echo -17 > /proc/<pid>/oom_adj # 旧内核兼容
Linux 5.11+ 引入了 OOMD(OOM Daemon) ——systemd 守护进程,支持基于 PSI(Pressure Stall Information)的主动内存压力预警:
# /etc/systemd/oomd.conf
[OOM]
DefaultMemoryPressureDurationSec=20s
DefaultMemoryPressureLimit=60%
# 内存压力连续 20s 超过 60% → omd 选择 cgroup 终止
# 比内核 OOM 更温和,更早介入
九、实战调优指南
9.1 诊断工具链
# 查看当前 swap 使用
swapon --show
free -h
# 查看页面回收统计
grep -E 'pgscan|pgsteal|pswpin|pswpout' /proc/vmstat
# pgscan_kswapd → kswapd 扫描的页面数
# pgscan_direct → 直接回收扫描的页面数
# pgsteal_kswapd → kswapd 实际回收的页面数
# pswin/pswpout → swap 换入/换出页数
# 查看水位标记
cat /proc/zoneinfo | grep -E 'Node|zone|min|low|high'
# cgroup 内存压力监控
cat /sys/fs/cgroup/memory/memory.pressure # PSI
cat /sys/fs/cgroup/memory/memory.stat # 详细统计
# slab 缓存占用
slabtop
9.2 常见场景调优矩阵
| 场景 | vm.swappiness | zswap | 调优要点 |
|---|---|---|---|
| Kubernetes 节点 | 0 | 推荐启用 | 依赖 cgroup 限额,避免 swap 使调度失准 |
| PostgreSQL 数据库 | 1-5 | 推荐启用 | 保留文件系统缓存,减少随机页面回收 |
| Redis/Elasticsearch | 0-10 | 不启用(大页) | 使用 HugePages,禁用 swap 避免延迟抖动 |
| Spark/MapReduce | 10-30 | 推荐启用 | 容器化部署,zswap 吸收偶发内存尖峰 |
| 桌面开发机 | 60(默认) | 可选 | 保持默认平衡即可 |
9.3 监控告警阈值建议
# 关键告警规则(Prometheus 表达式)
# 1. swap 使用率告警(swap 使用率 > 80% 持续 5 分钟)
(node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes)
/ node_memory_SwapTotal_bytes > 0.8
# 2. 直接回收率过高(每秒 direct reclaim > 100 次)
rate(node_vmstat_pgscan_direct[1m]) > 100
# 3. OOM 事件监控(dmesg 中存在 "oom-kill")
increase(node_vmstat_oom_kill[5m]) > 0
# 4. zswap 效率(压缩率 < 50% 意味着数据不适合压缩)
node_zswap_pool_total_size_bytes / node_zswap_stored_pages
> PAGE_SIZE / 2 # 压缩效果差
十、总结
Linux 内核的页回收与 Swap 机制是一个多层级的自动调控体系:
- LRU 双链表实现了页面冷热分层,决定了"谁更适合被回收"
- kswapd 守护进程在后台持续工作,尽量保持在 WMARK_HIGH 以上
- 直接回收作为紧急手段,由分配者同步执行,代价最高
- Swap 子系统为匿名页提供了磁盘逃生通道,Swap Cache 避免了换入换出颠簸
- zswap在物理内存和磁盘之间加入压缩缓存层,大幅降低 swap I/O 频率和延迟
- OOM Killer / systemd-oomd作为最后防线,在榨尽一切手段后终止进程
理解这一体系,对于构建低延迟服务、排查周期性卡顿、容器化内存管理都至关重要。真正的性能问题往往不是"内存不够",而是回收路径上某一层配置的失衡——swappiness 太大导致缓存频繁丢失、zswap 池大小不足无法吸收内存尖峰、cgroup 限制迫使过早 OOM 而 kswapd 其实还有能力回收文件页。
带着这些原理去读 /proc/vmstat、/sys/kernel/debug/zswap/*、cat /proc/zoneinfo 的输出时,那些数字就不再是冰冷的统计,而是内核呼吸的脉搏。

发表评论 取消回复