引言

当系统物理内存从充裕走向紧张,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 的区别

维度zswapzram
本质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.swappinesszswap调优要点
Kubernetes 节点0推荐启用依赖 cgroup 限额,避免 swap 使调度失准
PostgreSQL 数据库1-5推荐启用保留文件系统缓存,减少随机页面回收
Redis/Elasticsearch0-10不启用(大页)使用 HugePages,禁用 swap 避免延迟抖动
Spark/MapReduce10-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 机制是一个多层级的自动调控体系:

  1. LRU 双链表实现了页面冷热分层,决定了"谁更适合被回收"
  2. kswapd 守护进程在后台持续工作,尽量保持在 WMARK_HIGH 以上
  3. 直接回收作为紧急手段,由分配者同步执行,代价最高
  4. Swap 子系统为匿名页提供了磁盘逃生通道,Swap Cache 避免了换入换出颠簸
  5. zswap在物理内存和磁盘之间加入压缩缓存层,大幅降低 swap I/O 频率和延迟
  6. OOM Killer / systemd-oomd作为最后防线,在榨尽一切手段后终止进程

理解这一体系,对于构建低延迟服务、排查周期性卡顿、容器化内存管理都至关重要。真正的性能问题往往不是"内存不够",而是回收路径上某一层配置的失衡——swappiness 太大导致缓存频繁丢失、zswap 池大小不足无法吸收内存尖峰、cgroup 限制迫使过早 OOM 而 kswapd 其实还有能力回收文件页。

带着这些原理去读 /proc/vmstat、/sys/kernel/debug/zswap/*、cat /proc/zoneinfo 的输出时,那些数字就不再是冰冷的统计,而是内核呼吸的脉搏。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部