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:避免大页分配抖动
绑定 NUMAnumactl 绑定确保内存位于本地节点
关闭 THPecho 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 内核页面回收机制对于系统性能优化至关重要。以下是各层调优的优先级:

  1. 预防为主:通过合理设置 watermark_scale_factor 和预留 min_free_kbytes,让 kswapd 提前介入
  2. 减少直接回收:监控 allocstall_direct,将其作为内存压力的早期指标
  3. 工作负载适配:根据应用类型选择合适的 swappiness 和 dirty 参数
  4. 容器化环境:善用 cgroup v2 的 memory.low 和 memory.high 实现隔离与限流
  5. NUMA 感知:确保关键进程的内存分配位于本地节点,避免跨节点访问

在生产环境中,建议建立内存使用基线监控,包括页面回收频率、kswapd CPU 占用、以及直接回收事件数。这些指标一旦出现趋势性增长,应立即预警,避免系统进入不可控的内存压力状态。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.356976s