Linux 内核页面回收深度实战:LRU 算法、kswapd 与内存水位线
Linux 内核的页面回收(Page Reclaim)机制是维持系统内存平衡的关键子系统。当物理内存不足时,内核需要通过复杂而精细的算法选择最合适的页面换出或丢弃,以确保系统持续稳定运行。本文将深入剖析页面回收的核心算法——近似 LRU、内核线程 kswapd 的工作流程、以及内存水位线(Watermark)的调控策略,并通过实战案例展示如何进行调优。
一、页面回收概述
Linux 内核在内存分配路径中经历以下几个阶段:
快速路径(Fast Path)
│
├── 分配器有空闲页面 → 直接返回(无开销)
│
▼
慢速路径(Slow Path)
│
├── 异步回收(Direct Reclaim 的轻量模式)
│
▼
直接回收(Direct Reclaim)
│
├── 由申请内存的进程执行回收 → 性能抖动
│
▼
OOM Killer
│
└── 终止进程 → 最后手段
理解这个层次关系至关重要:kswapd 负责在后台异步回收内存,避免内存降至水位线以下;当 kswapd 来不及回收时,申请内存的进程会走慢速路径甚至直接回收,这被称为“直接回收抖动”(Direct Reclaim Storm),是延迟抖动的主要来源之一。
二、近似 LRU 算法
2.1 为什么需要近似 LRU
真正的 LRU(Least Recently Used)算法需要维护一个全局精确的访问顺序链表,这要求在每次内存访问时更新链表,开销巨大。因此,Linux 内核几乎不使用真正的 LRU,而是采用近似 LRU(Approximate LRU)——通过访问位(Accessed Bit)和老化算法来近似判断页面的活跃程度。
2.2 双链算法(Two-List LRU)
Linux 内核将页面分为两个链表:活跃链表(Active List)和非活跃链表(Inactive List)。页面首先进入非活跃链表,如果被再次引用则提升到活跃链表。活跃链表的尾部页面在老化后降级到非活跃链表,非活跃链表尾部的页面则被回收。
页面生命周期:
新分配页面
│
▼
┌─────────────────┐ 被访问 2 次 ┌─────────────────┐
│ 非活跃链表尾部 │ ──────────────────→ │ 活跃链表头部 │
│ (Inactive List) │ │ (Active List) │
└─────────────────┘ └─────────────────┘
│ │
│ 老化降级(不被访问) │ 老化降级
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 非活跃链表尾部 │ ←────────────────── │ 活跃链表尾部 │
│ (回收候选页面) │ │ (有一定年龄) │
└─────────────────┘ └─────────────────┘
│
│ 回收
▼
页面写入 Swap / 释放
2.3 页面老化机制
页面老化的判断依赖于访问位(Accessed Bit),由 CPU 硬件在访问页面时自动设置。内核定期检查该位来决定是否提升或降级页面。具体流程如下:
内核访问位检查流程:
页面 PTE 中的 Accessed Bit = 0
│
├── CPU 访问页面(读/写)
│ MMU 自动设置 Accessed Bit = 1
│
├── 内核 shrink_page_list() 中发现 Accessed Bit = 1
│ → 页面位于非活跃链表:提升(Promote)到活跃链表
│ → 页面位于活跃链表:保持不变
│ → 清除 Accessed Bit(留给下一次检查)
│
└── 第二次检查时仍为 0
→ 真正不活跃,准备回收
这意味着一个页面需要被连续两次逃脱回收检查才能证明它是"活跃"的。这种设计避免了瞬时访问对页面活性判断的干扰。
2.4 LRU 列表的组织结构
在现代 Linux 内核中(MULTI LRU 特性,Linux 6.x),LRU 列表按类型、NUMA 节点和 cgroup 进行组织,层次如下:
LRU 列表层次结构:
pgdat = pglist_data(每个 NUMA 节点一个)
│
└── lruvec = lruvec(每个 cgroup 一个)
│
├── active,anon — 活跃匿名页
├── inactive,anon — 非活跃匿名页(swap 候选)
├── active,file — 活跃文件页
└── inactive,file — 非活跃文件页(drop 候选)
当需要回收内存时,内核按照一定比例从不同类型的 LRU 列表中选择页面。匿名页(anonymous page)需要写入 swap 空间,文件页(file-backed page)可以直接丢弃(如果脏页则需要写回磁盘)。
三、kswapd 工作机制
3.1 kswapd 守护线程
kswapd 是内核中的内存回收守护线程,每个 NUMA 节点对应一个 kswapd 进程。当内存节点的空闲页面降至某个水位线(watermark)以下时,kswapd 被唤醒并开始回收内存,直到内存恢复到高水位线以上。
kswapd 工作流程:
kswapd_wait (等待状态)
│ 被唤醒(free pages < low_wmark)
▼
kswapd_try_to_sleep()
│ 进入回收循环
▼
balance_pgdat()
│
├── shrink_node()
│ │
│ ├── shrink_node_memcg() // 按 cgroup 回收
│ │ │
│ │ ├── shrink_list() // 遍历 LRU 列表
│ │ │ │
│ │ │ ├── shrink_active_list()
│ │ │ └── shrink_inactive_list()
│ │ │
│ │ └── shrink_slab() // 回收 slab 缓存
│ │
│ └── shrink_node_pgdat() // 跨 cgroup 回收
│
├── 检查是否达到高水位线
│ └── 达到 → 重新进入睡眠
│ └── 未达到 → 继续回收
▼
睡眠直到下次唤醒
3.2 kswapd 的渐进式重平衡
kswapd 不是一次性回收所有需要的内存,而是通过渐进式重平衡(Progressive Rebalance)策略,分为多个优先级(priority)逐级尝试更激进的回收:
kswapd 优先级(从高到低):
Priority 12 (DEF_PRIORITY) → 仅回收非活跃页面(最轻量) │
Priority 11 → 增加页面扫描深度 │
Priority 10 → 开始回收 slab 缓存 │ 越来越激进
Priority 09 → 跳过 cgroup 限制扫描 │
Priority 08 → 限制直接回收 │
Priority 07 → 直接页面回写脏页 │
Priority 01 → 跳过页面引用检查,强制回收(类似于 OOM) ▼
优先级越低,回收操作越激进。kswapd 开始时使用最高优先级(12),如果无法完成回收目标,则逐步降低优先级。这种渐进式设计确保了系统不会因内存回收而产生过大的性能抖动。
3.3 kswapd 使用 BPF 监控
通过 BPF 工具,我们可以深入分析 kswapd 的行为:
# 使用 bpftrace 追踪 kswapd 唤醒
bpftrace -e '
kprobe:try_to_wake_up /comm == "kswapd0"/ {
printf("kswapd0 woken up at %llu\n", nsecs);
}'
# 追踪页面回收统计
bpftrace -e '
tracepoint:mm_vmscan_kswapd_sleep {
printf("kswapd%d sleep\n", args->nid);
}'
# 监控直接回收次数(关键性能指标)
bpftrace -e '
kprobe:do_try_to_free_pages {
printf("Direct reclaim at %llu by %s[%d]\n", nsecs, comm, pid);
}'
四、内存水位线调控
4.1 三级水位线
Linux 内核在 NUMA 节点级别定义了三级水位线:min、low、high。它们决定了 kswapd 何时唤醒以及直接回收的触发时机:
空闲页面数量
│
│ 高水位线 (high) ─────────── kswapd 停止回收
│ ┌──────────────┐
│ │ 安全区域 │
│ │ (无需回收) │
│ 低水位线 (low) ────────────┼──────────────┘
│ │ kswapd 开始唤醒
│ ┌──────────────┐
│ │ 压力区域 │
│ │ (kswapd 回收) │
│ 最小水位线 (min) ──────────┼──────────────┘
│ │ 直接回收触发
│ ┌──────────────┐
│ │ 危险区域 │
│ │ (直接回收 + │
└────────────────────────────│ OOM 风险) │
└──────────────┘
每条水位线的页面数量计算公式如下:
页面数量 = (TotalRAM × Percentage) / PAGE_SIZE
典型比例(默认值,受 vm.min_kbytes 和 vm.watermark_scale_factor 影响):
high ≈ 总内存的 1%
low ≈ 总内存的 0.5%
min ≈ 总内存的 0.1%(但不低于 4 * PAGE_SIZE)
实际查询:
cat /proc/zoneinfo | grep -E "min|low|high"
4.2 水位线调节参数
# 查看当前水位线百分比
cat /proc/sys/vm/min_free_kbytes # 默认值:系统自动计算的 min 值
# 调整比例因子(推荐范围 10-500)
# 值越大,kswapd 越早启动(预防性更强)
echo 200 > /proc/sys/vm/watermark_scale_factor
echo 100 > /proc/sys/vm/watermark_boost_factor # 额外提升
# 查看 NUMA 各节点水位线
numactl --hardware
grep -A 5 "Node 0" /proc/zoneinfo
实战建议:对于内存密集型应用(数据库、大数据),建议适度提高
watermark_scale_factor,让 kswapd 更早启动,避免在内存压力下被动触发直接回收。对于延迟敏感型应用(高频交易),则反向调低该值,缩短回收延迟但增加 OOM 风险。
4.3 cgroup v2 的水位线控制
cgroup v2 提供了更精细化的水位线控制,允许为每个容器单独设置内存压力阈值:
# cgroup v2 内存高低水位控制
echo "max 2G" > /sys/fs/cgroup/db/memory.max
echo "high 1.8G" > /sys/fs/cgroup/db/memory.high # 触发限流但不会 OOM
echo "low 1.5G" > /sys/fs/cgroup/db/memory.low # kswapd 的目标水位
# 监控内存压力事件
cat /sys/fs/cgroup/db/memory.pressure
# 输出格式:
# some avg10=2.56 avg60=1.23 avg300=0.45 total=123456789
# full avg10=0.12 avg60=0.08 avg300=0.03 total=1234567
其中 some 表示部分内存压力(至少一个任务在等待内存),full 表示全任务压力(所有任务都受到内存压力)。这些压力值可以作为自动扩缩容的信号源。
五、实战:降低直接回收抖动
5.1 诊断直接回收问题
直接回收是延迟抖动的首要原因之一。以下方法可以直接量化直接回收的发生频率:
# 查看系统级别的直接回收统计
grep -E "allocstall|compactstall|pgscan_direct" /proc/vmstat
# 持续监控(每秒)
vmstat 1 10
# 检查列:
# allocstall_direct — 直接回收的次数(越低越好)
# kswapd_high_wmark_hit_quickly — kswapd 频繁唤醒(内存压力大)
# 通过 BPF 捕获直接回收的调用栈
bpftrace -e '
kprobe:do_try_to_free_pages {
@[kstack(8)] = count();
}'
5.2 优化策略
降低直接回收抖动的核心思路是让 kswapd 更早、更高效地工作,具体策略包括:
| 策略 | 参数 | 适用场景 |
|---|---|---|
| 预防性提前唤醒 | 提高 watermark_scale_factor | 内存压力频繁的系统 |
| 调整 swappiness | 降低 swappiness(10-30) | 数据库:减少匿名页回收 |
| 加速 slab 回收 | 调整 vfs_cache_pressure | 文件元数据较多的系统 |
| 大页预留 | 配置 hugepages 池 | DPDK/HPC:避免大页分配抖动 |
| 绑定 NUMA | numactl 绑定 | 确保内存位于本地节点 |
| 关闭 THP | echo never | 对延迟抖动极度敏感的场景 |
5.3 具体配置示例
# MySQL 数据库服务器优化
vm.swappiness = 10 # 优先回收文件缓存,避免 swap 抖动
vm.dirty_ratio = 40 # 脏页比例达到 40% 时强制回写
vm.dirty_background_ratio = 10 # 后台回写阈值
vm.min_free_kbytes = 2097152 # 预留 2GB 给内核分配器
vm.zone_reclaim_mode = 0 # 关闭 NUMA 本地回收(避免回收)
# 写入 /etc/sysctl.d/99-mysql-memory.conf
sysctl -p /etc/sysctl.d/99-mysql-memory.conf
六、页面回收与内存压缩
6.1 内存碎片整理
当系统运行较长时间后,内存碎片化可能成为问题——虽然总空闲内存足够,但无法分配连续的物理页面。Linux 内核通过以下机制应对:
- 迁移类型分组:将页面按可迁移性分组(不可迁移、可回收、可移动),减少跨类型碎片
- kcompactd 线程:后台执行内存碎片整理
- 主动碎片整理:内核参数
vm.extfrag_threshold控制触发敏感度
/proc/sys/vm/compact_memory
# 调整碎片整理策略
echo 0 > /proc/sys/vm/compact_unevictable_allowed # 不整理不可驱逐页
echo 500 > /proc/sys/vm/extfrag_threshold # 碎片阈值 (0-1000)
6.2 Zswap 与内存压缩交换
Zswap 是 Linux 内核中一个压缩交换页面缓存层,它在将页面写入磁盘之前先尝试压缩存储在内存中。这可以显著减少 I/O 延迟:
# 检查 zswap 是否启用
dmesg | grep -i zswap
# [ 0.000000] Command line: ... zswap.enabled=1
# 查看 zswap 统计
cat /sys/kernel/debug/zswap/pool_total_size
cat /sys/kernel/debug/zswap/stored_pages
cat /sys/kernel/debug/zswap/writeback_enabled
# 内核启动参数配置
# zswap.enabled=1 zswap.compressor=lz4 zswap.max_pool_percent=20 zswap.zpool=zsmalloc
# 与 ZRAM 的区别:
# ZRAM:完全在内存中的压缩块设备(替代 swap 分区)
# Zswap:压缩缓存层,将压缩页面暂存于内存中,最终仍需写入磁盘 swap
七、总结与性能基线建议
理解 Linux 内核页面回收机制对于系统性能优化至关重要。以下是各层调优的优先级:
- 预防为主:通过合理设置
watermark_scale_factor和预留min_free_kbytes,让 kswapd 提前介入 - 减少直接回收:监控
allocstall_direct,将其作为内存压力的早期指标 - 工作负载适配:根据应用类型选择合适的 swappiness 和 dirty 参数
- 容器化环境:善用 cgroup v2 的 memory.low 和 memory.high 实现隔离与限流
- NUMA 感知:确保关键进程的内存分配位于本地节点,避免跨节点访问
在生产环境中,建议建立内存使用基线监控,包括页面回收频率、kswapd CPU 占用、以及直接回收事件数。这些指标一旦出现趋势性增长,应立即预警,避免系统进入不可控的内存压力状态。

发表评论 取消回复