在 Linux 内核的宏大内存管理架构中,伙伴系统(Buddy System)负责页面的分配,而与之配套的另一半——页面回收(Page Reclamation),则负责在内存紧张时释放页面。页面回收的核心执行者是 Kswapd 内核线程。如果没有高效及时的回收,系统将迅速陷入内存压力,最终触发 OOM(Out Of Memory)杀手。

本文将深入剖析 Linux 内核页面回收机制的完整链路、LRU 算法设计、直接回收与异步回收的区别、ZRAM/ZSwap 压缩交换技术,以及实际工程中的调优方法论。

一、页面回收的核心问题:何时回收、回收哪些

操作系统需要决定在内存使用率达到什么阈值时启动回收,以及当可用内存不足时应该优先回收哪些页面。这是两个最关键的设计问题:

  • 何时回收(When):由水位线(Watermarks)决定——min、low、high 三个阈值将内存划分为不同压力区间
  • 回收哪些(Which):由 LRU(Least Recently Used)算法决定——每个内存区域维护活跃/不活跃链表,通过页面访问的时效性判断其回收价值

二、LRU 算法:Linux 内核的五链表设计

2.1 基本原理

LRU 算法基于一个核心假设:过去一段时间内未被访问的页面,短期再次被访问的概率较低,因此优先回收。早期的 Linux 内核使用简单的 Active/Inactive 双链表,但随着系统规模和内存容量的增长,这种设计暴露出显著缺陷:扫描开销大(全局链表过长)、冷热判断粗糙(仅两个等级)、NUMA 不友好(跨节点回收延迟不可控)。

为此,内核社区引入了 Refault Distance 评估和 五链表 LRU 架构,将冷热判断从2级扩展到5级。

2.2 五链表结构

现代内核(5.x+)每个 NUMA 节点(pglist_data)维护以下五条 LRU 链表:

LRU_INACTIVE_FILE ← 不活跃文件页(缓冲页缓存,可回收)
LRU_ACTIVE_FILE ← 活跃文件页(热缓冲页缓存)
LRU_INACTIVE_ANON ← 不活跃匿名页(用户态未访问)
LRU_ACTIVE_ANON ← 活跃匿名页(用户态最近访问)
LRU_UNEVICTABLE ← 不可回收锁定页面

页面如何在链表间移动?

  • 页面首次加入时放入 Inactive 链表尾部
  • 如果 Inactive 中的页面在扫描前被再次访问,则标记 PG_referenced 标志,下次扫描时提升到 Active 链表
  • Active 链表中的页面如果经过两轮扫描仍保持 referenced 状态,继续留在 Active 链表(真正热页)
  • 如果 Active 链表中的页面经过两轮扫描没有被访问,则降级到 Inactive 链表
  • Inactive 链表尾部的页面是最冷最先被回收的对象

2.3 各页面的生命周期路径

文件页(File Page / Page Cache):

文件读取 → LRU_INACTIVE_FILE → (被再访问)→ LRU_ACTIVE_FILE
↘ 未再访问,继续溜到尾部 → shrink_list() → 释放或回写

匿名页(Anonymous Page):

malloc/mmap → LRU_INACTIVE_ANON → (被再访问)→ LRU_ACTIVE_ANON
↘ 未再访问 → 写入 swap 分区 → 释放页面

2.4 交换倾向(Swappiness)

系统通过 vm.swappiness 控制回收时文件页与匿名页的比例:

swappiness 范围 0-200,默认值在 5.15+ 内核为 60
swappiness=0:几乎只回收文件页(除非free内存极低)
swappiness=60:平衡回收文件页和匿名页
swappiness=100:文件页与匿名页权重相等
swappiness=200:完全偏向回收匿名页

从 Linux 5.15 开始,scrubd 和 MGLRU(Multi-Gen LRU)的引入进一步提升了页面回收的精准度。MGLRU 放弃了传统的 Active/Inactive 二元分类,采用多代(Multi-Generation)模型,每轮扫描根据访问热度将页面划分为不同代数,年轻的页面更容易保留在内存中。

三、回收触发路径:异步回收 vs 直接回收

3.1 异步回收:Kswapd

Kswapd 是每个 NUMA 节点对应一个的专用内核线程(例如 kswapd0、kswapd1)。它由 init_mm 中的交换初始化函数创建,平时处于 schedule_timeout 睡眠状态,在水位线低于 WMARK_LOW 时被伙伴系统的直接分配路径或定时器唤醒。

Kswapd 的工作流程大致如下:

kswapd 执行流程

1. 进入 try_to_free_pages(),准备回收
2. 检查当前水位是否 >= WMARK_LOW,若是则休眠
3. 调用 balance_pgdat() 执行实际回收
4. shrink_node() → shrink_pgdat() 遍历 LRU 链表尾部
5. 写回脏页(kflushd/kupdate 异步回写)
6. 对于匿名页,写入 swap
7. 休眠直到下一次被唤醒

唤醒条件:

  • 伙伴系统页面分配时发现 zone->managed_pages 中可用页面低于 low_wmark
  • 内核线程 kswapd 定时器检查发现水位下降
  • 内存压力通知器(Memory Pressure Notifier)报告严重压力
  • 透明大页(THP)分裂失败触发紧急回收

3.2 直接回收(Direct Reclaim)

直接回收发生在进程自身的执行上下文中。当伙伴系统在 alloc_pages() 快速路径失败时,会调用 _alloc_pages_direct_reclaim,将当前进程加入回收工作队列。

直接回收的代价

直接回收最大的问题是阻塞当前进程。在高内存压力期间,进程在 alloc_pages() 调用中可能被长时间阻塞,导致应用延迟抖动、响应超时。常见场景:
• 日志写入(write → page cache → 直接回收)
• 网络读取(read → socket buffer → 直接回收)
• 数据库检查点刷盘(fsync → 脏页回写 → 直接回收)

判断直接回收的指标:

pgsteal_kswapd # kswapd 回收的页面数
pgsteal_direct # 直接回收的页面数
pgscan_kswapd # kswapd 扫描的页面数
pgscan_direct # 直接回收扫描的页面数
compact_stall # 触发内存整理的总次数
compact_fail # 内存整理失败次数
compact_success # 内存整理成功次数

3.3 回收路径对比

维度Kswapd 异步回收Direct Reclaim 直接回收
执行上下文内核线程(kswapd%d)调用者进程上下文
触发条件水位 < low_wmarkalloc_pages 快速路径失败
对调用者的影响透明无感同步阻塞,可能耗时长达秒级
典型场景后台异步唤醒回收Nginx proxy_buffering、Kafka Producer send

四、页面回收的精细机制

4.1 页面写出与回收

回收不是一删了之,还需要确保数据一致性。页面回收的核心操作分三种:

  • 干净文件页(Clean File Page):内存副本大于等于磁盘版本,直接释放 page 结构
  • 脏文件页(Dirty File Page):需要先回写到磁盘(writeback),然后才能释放。由 wb_workfn 写回线程异步执行
  • 匿名页(Anonymous Page):写入 swap 空间,并维护交换映射关系(swap_entry)

页面回收时还会检查 SwapCache —— 已写入 swap 但又被访问的匿名页(page fault 重新加载),直接释放 swap slot 并恢复页面。

4.2 THP 分裂与页面的重新合并

透明大页(THP)分配时是 2MB 的大页,但在回收时可能面临碎片化问题。Kswapd 在页面碎片化到一定程度时会通过 __alloc_pages_direct_compact 触发内存整理(Compaction),将小页合并为大页。

4.3 Unevictable LRU 与锁定页面

某些页面不允许被回收:mlock / mlockall 锁定的页面(PINNED)、共享内存被 shmctl(SHM_LOCK) 锁定的页面、内核代码段和 per-cpu 数据、设备映射 / DMA 缓冲区。这些页面进入 LRU_UNEVICTABLE 链表,shrink_lru_vma() 扫描时直接跳过。

五、交换空间(Swap)与压缩交换技术

5.1 Swap 基本原理

当匿名页被回收时,内容必须写入 swap 分区或 swap 文件。内核使用 Swap Area 结构维护交换空间:

struct swap_info_struct {
    unsigned long flags; // SWP_USED / SWP_WRITEOK
    int prio; // 优先级(多 swap 区域排序)
    struct plist_head swap_avail_heads; // 可用 swap slot
};

// 每个 swap slot(通常 4KB)对应一个 swp_entry_t
// swp_entry_t = (type << 1) | offset —— type 是 swap area 索引(最多31个),offset 是 slot 号

5.2 ZRAM —— 内存压缩交换

ZRAM(原 CompCache)将 swap 存储在压缩后的内存中,而非磁盘。

匿名页 → ZRAM 设备(/dev/zram0)→ 压缩(LZ4/LZ4HC/ZSTD/LZO-RLE 等算法)→ 存储在内存中的压缩页
                                                    → 释放原始页面
访问压缩页 → 分配新页 → ZRAM 解压 → 恢复数据

ZRAM 的关键特点:无需磁盘 I/O,适合嵌入式/无盘设备;压缩比通常为 2:1 ~ 3:1;CPU 消耗增加;Linux 5.0 开始支持多压缩算法动态切换。

5.3 ZSwap —— 交换缓存层

ZSwap 是介于磁盘 swap 与内存之间的压缩写回缓存。当 swap 写出时先压缩存入内存池,延迟到内存压力增大时才刷回磁盘。

与 ZRAM 的关键区别:ZRAM 是独立 swap 设备(/dev/zramX),不需要磁盘 swap;ZSwap 是磁盘 swap 的缓存层,需要底层磁盘后端,但缓存满载后可通过回写磁盘释放,不会 OOM。

六、OOM 杀手的最后防线

当直接回收和 Kswapd 都无法挤出足够的内存时,内核最后的防线是 OOM Killer(Out Of Memory Killer)。它选择一个进程终止以释放大量内存。

6.1 OOM 的评分机制

OOM Killer 通过 oom_badness() 为每个进程计算分数(0~1000):

// oom_badness 评分:
// 分数 = 总内存占用比例 × 1000 × (1 + oom_score_adj / 1000)
// 总内存 = RSS + swap 使用 + page table 占用 + 内核栈

// oom_score_adj 调整:
// OOM_SCORE_ADJ_MIN (-1000):永远不会被 OOM 杀掉
// -700:重点保护
// 0:默认值
// OOM_SCORE_ADJ_MAX (+1000):优先被杀

6.2 保护关键进程

# 保护 redis-server 不被 OOM 杀死
$ echo -1000 > /proc/$(pidof redis-server)/oom_score_adj

# systemd 服务可通过 OOMPolicy= 或 ExecStartPre= 设置
# cgroup v2 使用 memory.oom.group

七、工程调优:从理论到实践

7.1 内核参数调优

# vm.swappiness —— 交换倾向(数据库10~20,Web 60,嵌入式0)
vm.swappiness = 20

# vm.dirty_ratio —— 脏页占RAM比例阻塞阈值(默认20%)
vm.dirty_ratio = 30

# vm.dirty_background_ratio —— 后台刷盘阈值
vm.dirty_background_ratio = 10

# vm.min_free_kbytes —— 保留最小空闲内存(64GB可设1GB)
vm.min_free_kbytes = 1048576

# vm.vfs_cache_pressure —— 文件缓存回收权重(默认100)
vm.vfs_cache_pressure = 200

# vm.zone_reclaim_mode —— NUMA节点本地回收(0默认,1优先本地)
vm.zone_reclaim_mode = 1

7.2 监控指标体系

# /proc/meminfo —— 整体内存状态
$ cat /proc/meminfo | grep -E "MemFree|Cached|AnonPages|Shmem|SwapTotal|SwapFree"

# /proc/vmstat —— 回收统计
$ grep -E "pgsteal|pgscan|compact|oom" /proc/vmstat

# /proc/buddyinfo —— 伙伴系统内存碎片
$ cat /proc/buddyinfo

# /proc/<pid>/smaps_rollup —— 进程级内存占用
$ cat /proc/<pid>/smaps_rollup

# /proc/zoneinfo —— 节点级内存区域详情含水位线
$ grep -E "Node|zone|min|low|high" /proc/zoneinfo

7.3 常见内存回收相关性能问题

问题 1:直接回收频繁导致应用延迟抖动

  • 症状:pgscan_direct 快速增长,P99 延迟飙升
  • 原因:页面分配速率超过 Kswapd 回收速率
  • 对策:提前唤醒 Kswapd(降低 low 水位),或优化应用内存使用

问题 2:Swap 频繁导致 CPU 饱和(swap storm)

  • 症状:磁盘 I/O 占用 100%,CPU iowait 高,swap 频繁 in/out
  • 原因:swappiness 过高或内存过小
  • 对策:降低 swappiness,检查并终止异常进程,增加 ZRAM

问题 3:OOM 误杀关键进程

  • 症状:数据库、MQ 等核心服务突然终止
  • 原因:OOM Killer 评分偏差或未设置 oom_score_adj
  • 对策:使用 systemd OOMPolicy 或 cgroup memory.max + 优先级保护

7.4 cgroup v2 内存控制

$ echo "512M" > /sys/fs/cgroup/myapp/memory.max
$ echo "256M" > /sys/fs/cgroup/myapp/memory.min
$ echo "384M" > /sys/fs/cgroup/myapp/memory.low
$ echo "448M" > /sys/fs/cgroup/myapp/memory.high
$ cat /sys/fs/cgroup/myapp/memory.current
$ cat /sys/fs/cgroup/myapp/memory.events # oom / oom_kill / high / max / low

八、总结与演进方向

页面回收是 Linux 内核内存管理中不可或缺的一环。从 Kswapd 的异步回收,到直接回收的临时干预,再到 OOM Kill 的最后防线,内核构建了一套多层次的内存保障体系。

关键技术演进:

  • MGLRU(Multi-Gen LRU):Linux 6.1 引入实验性支持,替代传统 Active/Inactive 二元分类,通过多代年龄模型更精准识别冷热页面
  • DAMON(Data Access Monitor):基于硬件访问位的精确采样,识别真正的热页面和冷页面
  • PSI(Pressure Stall Information):/proc/pressure/memory 提供更细粒度的内存压力指标
  • MGLRU + DAMON 联合使用:未来趋势是利用 DAMON 的访问频率数据驱动 MGLRU 的页面老化决策

理解 Kswapd 和页面回收机制,对于诊断和解决生产环境中的内存性能问题至关重要。建议运维和开发者结合 Buddy System、SLUB 分配器,以及本篇文章的回收机制,形成对 Linux 内存管理全景式的理解。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部