1.5 页面回收与交换reclaim/swap:内存不够时怎么办

本篇目标:理解 Linux 如何在物理内存不足时回收页面。我们将深入 LRU 链表、kswapd 守护进程、直接回收(direct reclaim)以及 swap 机制,看内核如何在"内存紧张"与"系统响应"之间取得平衡。这些机制是理解 HMM 页面迁移的重要背景------设备内存(如 GPU VRAM)可以作为页面迁移的目标,类似于 swap 但性能更好。


1. 承上启下:分配与回收的平衡

前几篇我们建立了内存分配的完整链路:

  • 第 1-3 篇:虚拟地址(VMA)→ 页表 → 物理页帧(PFN)
  • 第 4 篇(缺页处理):首次访问时按需分配物理页

但有一个问题我们还没回答:物理内存是有限的 。如果进程不断分配内存,物理内存终将耗尽。此时,内核必须回收一些页面,腾出空间给新的分配请求,也就是swap功能。下面是加上了swap的内存的生命周期循环。

页面回收是内存管理中最复杂的部分之一,因为它需要:

  1. 选择牺牲品:哪些页面可以回收?哪些正在被使用?
  2. 处理脏页:修改过的页面需要先写回(swap 或文件系统)
  3. 保持响应性:不能让系统在回收时卡死
  4. 避免抖动:不能刚回收就又被访问,造成频繁换入换出

2. 选择牺牲品:哪些页面可以回收?

内存回收的第一步是找到合适的回收对象------哪些页面是"冷"的、可以安全回收?哪些正在被使用、必须保护?

2.1 LRU:近似最近最少使用

内核使用 LRU(Least Recently Used) 算法来判断页面的"冷热"程度:

  • 热页面:最近被访问过,可能很快还会被访问
  • 冷页面:很久没被访问,可以考虑回收

这基于一个假设:最近使用过的页面,在不久的将来更可能被再次使用(时间局部性)。

2.2 五条 LRU 链表

Linux 维护了 5 条 LRU 链表,定义在 include/linux/mmzone.h

c 复制代码
// include/linux/mmzone.h

enum lru_list {
    LRU_INACTIVE_ANON = 0,  // 不活跃匿名页(候选回收)
    LRU_ACTIVE_ANON   = 1,  // 活跃匿名页(暂不回收)
    LRU_INACTIVE_FILE = 2,  // 不活跃文件页(候选回收)
    LRU_ACTIVE_FILE   = 3,  // 活跃文件页(暂不回收)
    LRU_UNEVICTABLE   = 4,  // 不可回收页(mlock 等)
    NR_LRU_LISTS
};

两个维度

  • 匿名页 vs 文件页:匿名页需要写入 swap,文件页可以丢弃(干净)或回写(脏)
  • 活跃 vs 不活跃:活跃页暂时不回收,不活跃页是回收候选

2.3 Accessed 位:追踪页面冷热

页面的"活跃度"由硬件的 Accessed 位(PTE 中)来追踪:

c 复制代码
// 页面被访问时的流程

1. CPU 访问页面 → 硬件自动设置 PTE 的 Accessed 位
2. 内核定期扫描 → 检查 Accessed 位
3. 如果 Accessed=1:
   - 清除 Accessed 位
   - 页面保持在 Active 链表(或从 Inactive 提升到 Active)
4. 如果 Accessed=0:
   - 页面从 Active 降级到 Inactive
   - 如果已在 Inactive,成为回收候选

💡 为什么不直接用访问时间戳? 每次访问都更新时间戳开销太大。用 Accessed 位是一种"近似 LRU"------定期采样而非精确记录。

2.4 反向映射(rmap):找到所有映射者

要回收一个页面,必须先解除所有指向它的 PTE。但内核只知道 struct page,如何找到所有映射它的 PTE?

答案是 rmap(Reverse Mapping) ------每个 struct page 记录了"谁在映射我":

c 复制代码
// 反向映射的核心数据结构

struct folio {
    struct address_space *mapping;  // 文件页:所属文件
                                    // 匿名页:指向 anon_vma
    ...
};

// 通过 anon_vma 可以找到所有映射这个匿名页的 VMA
// 通过 VMA 可以找到 mm_struct
// 通过 mm_struct 可以遍历页表找到 PTE

💡 HMM 与 rmap :HMM 使用的 ZONE_DEVICE 页面也有 struct page,因此也能参与 rmap。这是 HMM 页面能被正确回收/迁移的基础。

2.5 swappiness:匿名页与文件页的取舍

内核需要决定回收匿名页还是文件页。swappiness 参数控制这个倾向:

bash 复制代码
# 查看当前值(默认 60)
cat /proc/sys/vm/swappiness

# 范围 0-200
# 0:尽量不换出匿名页,优先回收文件页
# 100:匿名页和文件页同等对待
# 200:积极换出匿名页

2.6 扫描强度:priority 控制搜索范围

struct scan_control 中的 priority 字段控制扫描强度:

c 复制代码
// 优先级从 DEF_PRIORITY (12) 开始
// 每次扫描不成功就降低优先级
// 优先级越低,扫描范围越大

扫描页面数 = LRU 链表长度 >> priority

priority=12: 扫描 1/4096 的页面
priority=11: 扫描 1/2048 的页面
...
priority=0:  扫描全部页面

2.7 OOM Killer:最后的手段

如果回收努力全部失败,内存仍然不足,OOM Killer 出手------选择一个进程杀掉:

c 复制代码
// mm/oom_kill.c

// 选择"最该死"的进程
// 考虑因素:内存使用量、oom_score_adj、是否是 root 等

static int oom_badness(struct task_struct *p, ...)
{
    // 计算进程的"罪恶值"
    // 值越高越可能被杀
}

⚠️ OOM Killer 是最后手段,到这一步说明系统内存严重不足。合理配置 swap 和监控内存使用可以避免 OOM。


3. 处理脏页:修改过的页面怎么办?

选中了要回收的页面后,下一个问题是:如果页面被修改过(脏页),内容怎么保存?

3.1 两类页面的不同归宿

页面类型 干净页 脏页
文件页 直接丢弃(可从文件重读) 回写到文件系统
匿名页 不存在"干净匿名页"概念 必须写入 swap

文件页有后备文件------干净页可以直接丢弃,脏页可以回写。但匿名页(堆、栈、mmap MAP_ANONYMOUS)没有后备文件,必须写到 swap。

3.2 Swap:匿名页的后备存储

复制代码
匿名页回收流程:

    ┌─────────────┐
    │  匿名页      │ 
    │ (在内存中)   │
    └─────────────┘
          │
          │ 回收时写入 swap
          ▼
    ┌─────────────┐
    │ swap 分区    │   PTE 被设为 swap entry
    │ (在磁盘上)   │   记录 swap 位置
    └─────────────┘
          │
          │ 再次访问时换入
          ▼
    ┌─────────────┐
    │  新物理页    │   从 swap 读取内容
    │ (在内存中)   │   恢复 PTE 映射
    └─────────────┘

3.3 Swap Entry:记录页面去了哪里

当页面被换出到 swap 时,原来的 PTE 被替换为 swap entry

c 复制代码
// include/linux/swapops.h

// swap entry 的格式(Present=0)
// ┌─────────────────────────────────────────────────┐
// │ type (哪个 swap 设备) │ offset (设备内偏移)    │ 0 │
// └─────────────────────────────────────────────────┘

// 创建 swap entry
static inline swp_entry_t swp_entry(unsigned long type, pgoff_t offset)
{
    swp_entry_t ret;
    ret.val = (type << SWP_TYPE_SHIFT) | (offset & SWP_OFFSET_MASK);
    return ret;
}

// 从 swap entry 提取信息
static inline unsigned swp_type(swp_entry_t entry);
static inline pgoff_t swp_offset(swp_entry_t entry);

swap entry 编码(我们在第 3 篇简要介绍过):

  • type:标识是哪个 swap 设备(可以有多个 swap 分区)
  • offset:在该 swap 设备中的页面偏移

3.4 换出流程(Swap Out)

c 复制代码
// 简化的换出流程

1. 选中一个匿名页进行回收
2. 分配 swap 槽位:get_swap_page()
3. 将页面内容写入 swap:swap_writepage()
4. 通过 rmap 找到所有映射这个页的 PTE
5. 将每个 PTE 替换为 swap entry:
   ptep_clear_flush()
   set_pte_at(swp_entry_to_pte(entry))
6. 释放物理页面回伙伴系统

3.5 换入流程(Swap In)

当 CPU 访问一个 swap entry 时,触发缺页异常,do_swap_page() 处理换入(我们在第 4 篇已详细介绍):

c 复制代码
// 简化的换入流程(do_swap_page)

1. 识别 PTE 是 swap entry
2. 提取 swap 位置:swp_type(), swp_offset()
3. 检查 swap cache(可能已被其他进程换入)
4. 从 swap 读取页面内容:swapin_readahead()
5. 分配新物理页,填充内容
6. 建立新的 PTE 映射
7. 释放 swap 槽位(如果是最后一个引用)

3.6 shrink_folio_list():回收执行的核心

不管是哪种触发方式,最终都会调用 shrink_folio_list() 逐个处理候选页面,其中脏页处理是关键步骤:

c 复制代码
// mm/vmscan.c(简化)

static unsigned int shrink_folio_list(struct list_head *folio_list,
                                      struct pglist_data *pgdat,
                                      struct scan_control *sc, ...)
{
    while (!list_empty(folio_list)) {
        struct folio *folio = lru_to_folio(folio_list);
        
        // 1. 尝试锁定页面
        if (!folio_trylock(folio))
            goto keep;  // 锁不到,跳过
        
        // 2. 检查是否可回收
        if (!folio_evictable(folio))
            goto activate_locked;  // mlock 的页面不能回收
        
        // 3. 检查是否被映射
        if (!sc->may_unmap && folio_mapped(folio))
            goto keep_locked;  // 不允许 unmap,跳过
        
        // 4. 处理脏页(核心!)
        if (folio_test_dirty(folio)) {
            // 文件页:启动回写
            // 匿名页:写入 swap
            ...
        }
        
        // 5. 解除所有页表映射(通过 rmap)
        if (folio_mapped(folio)) {
            try_to_unmap(folio, ...);
        }
        
        // 6. 真正释放页面
        if (folio可以释放) {
            free_unref_folios(...);
            nr_reclaimed++;
        }
    }
    return nr_reclaimed;
}

回收决策总结

因素 处理方式
被映射 需要先通过 rmap 解除所有 PTE 映射
脏页 匿名页写入 swap;文件页回写到磁盘
正在写回 等待或跳过
被锁定 跳过(稍后重试)
mlock 不可回收,移到 UNEVICTABLE 链表

4. 保持响应性:不让系统在回收时卡死

脏页需要写磁盘、swap 需要 I/O------这些都很慢。如何在回收的同时保持系统响应性?答案是分层设计:后台异步回收为主,阻塞式直接回收为最后防线。

4.1 kswapd:后台异步回收

每个 NUMA 节点有一个 kswapd 守护进程,在后台默默工作:

c 复制代码
// mm/vmscan.c

static int kswapd(void *p)
{
    pg_data_t *pgdat = (pg_data_t *)p;
    
    tsk->flags |= PF_MEMALLOC | PF_KSWAPD;  // 特殊标记
    
    for ( ; ; ) {
        // 睡眠,等待被唤醒
        kswapd_try_to_sleep(pgdat, ...);
        
        // 被唤醒后,开始回收
        reclaim_order = balance_pgdat(pgdat, alloc_order, highest_zoneidx);
    }
}

kswapd 在后台运行,不阻塞任何用户进程。它提前回收页面,维持一个"安全库存"。

4.2 Watermark:三条水位线的调度艺术

见上图。其设计精髓

  • high → low 之间:系统正常,kswapd 睡眠
  • low 以下:kswapd 被唤醒,后台异步回收(用户无感知)
  • min 以下:紧急状态,触发直接回收(用户可能感知到卡顿)

大部分情况下 kswapd 能在 low 和 high 之间维持平衡,用户无感知。

4.3 直接回收(Direct Reclaim):不得已的阻塞

如果 kswapd 来不及回收,空闲内存降到 min watermark 以下,分配请求会触发直接回收

c 复制代码
// mm/vmscan.c

unsigned long try_to_free_pages(struct zonelist *zonelist, int order,
                                gfp_t gfp_mask, nodemask_t *nodemask)
{
    struct scan_control sc = {
        .nr_to_reclaim = SWAP_CLUSTER_MAX,  // 至少回收这么多
        .gfp_mask = gfp_mask,
        .may_writepage = 1,
        .may_unmap = 1,
        .may_swap = 1,
    };
    
    // 同步回收,会阻塞当前进程!
    nr_reclaimed = do_try_to_free_pages(zonelist, &sc);
    
    return nr_reclaimed;
}

直接回收的问题

  • 阻塞:分配请求被挂起,等待回收完成
  • 延迟:如果需要写脏页,延迟可能很长
  • 级联:多个进程同时触发直接回收,系统变慢

💡 这就是为什么内存压力大时系统会"卡"------进程在等待直接回收完成。Watermark 的设计目标就是让系统尽量停留在 kswapd 异步回收阶段,避免走到直接回收。


5. 避免抖动:防止频繁换入换出

即使选对了牺牲品、正确写回了脏页、保持了响应性,还有一个陷阱:刚回收的页面马上又被访问,导致频繁换入换出(thrashing)。内核通过两个机制来避免这个问题。

5.1 Active/Inactive 两级过滤

为什么不用简单的单一 LRU 链表?考虑"扫描攻击"场景:

复制代码
单一 LRU 的问题:

  假设一次 `find /` 扫描了大量文件
  → 产生大量文件页,涌入 LRU
  → 把真正的热页面(如数据库缓存)挤到链表尾部
  → 热页面被回收 → 马上又被访问 → 抖动!

两级链表的保护机制

复制代码
Active/Inactive 的防护:

  find / 产生的文件页 → 进入 Inactive 链表
  (因为只被访问一次,Accessed 位只被设置一次)
  
  数据库热页面 → 保持在 Active 链表
  (持续被访问,Accessed 位反复被设置)
  
  结果:扫描产生的"一次性"页面优先被回收
        真正的热页面得到保护

关键规则 :页面需要在 Inactive 链表中再次被访问才会提升到 Active。仅被访问一次的页面会被优先回收------这正是避免抖动的核心设计。

5.2 Swap Cache:避免重复磁盘 I/O

考虑 fork 后的场景:

c 复制代码
pid_t pid = fork();
// 此时父子进程共享所有页面(COW)

// 假设某个共享页被换出到 swap
// 父子进程的 PTE 都变成同一个 swap entry

// 现在父进程访问这个页,触发换入
// 紧接着子进程也访问这个页

// 问题:需要从 swap 读两次吗?

Swap Cache 解决了这个问题------保存最近换入/换出的页面,避免重复 I/O:

复制代码
Swap Cache 机制:

父进程换入:
  1. 分配物理页
  2. 从 swap 读取内容
  3. 将页面加入 swap cache(key = swap entry)
  4. 建立父进程的 PTE 映射

子进程换入:
  1. 检查 swap cache:找到了!
  2. 直接使用 cache 中的页面
  3. 建立子进程的 PTE 映射
  4. 无需再次读取 swap

好处

  • 避免同一页面被多次从 swap 读取(减少 I/O,降低抖动)
  • 写入 swap 时也先进 cache,异步写入磁盘(减少写延迟)

6. 实验:观察页面回收

6.1 查看内存和 swap 状态

bash 复制代码
# 查看内存概况
free -h

# 查看详细内存统计
cat /proc/meminfo

# 关键指标:
# - MemFree: 完全空闲的内存
# - Buffers/Cached: 可回收的缓存
# - SwapTotal/SwapFree: swap 使用情况
# - Active/Inactive: LRU 链表统计

6.2 查看 kswapd 活动

bash 复制代码
# 查看 vmstat,观察 swap 活动
vmstat 1

# 关键列:
# si: swap in(每秒换入页数)
# so: swap out(每秒换出页数)
# 如果 si/so 持续很高,说明内存压力大

# 查看 kswapd 进程
ps aux | grep kswapd

6.3 模拟内存压力

c 复制代码
// stress_memory.c - 简单的内存压力测试
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    size_t size = 100 * 1024 * 1024;  // 100MB
    if (argc > 1)
        size = atol(argv[1]) * 1024 * 1024;
    
    printf("分配 %zu MB 内存...\n", size / (1024 * 1024));
    
    char *buf = malloc(size);
    if (!buf) {
        perror("malloc");
        return 1;
    }
    
    // 触碰所有页面,防止被优化掉
    printf("触碰所有页面...\n");
    memset(buf, 'A', size);
    
    printf("完成,按 Enter 释放...\n");
    getchar();
    
    free(buf);
    return 0;
}
bash 复制代码
# 编译并运行
gcc -o stress_memory stress_memory.c

# 在另一个终端观察 vmstat
vmstat 1

# 分配大量内存,观察 swap 活动
./stress_memory 500   # 分配 500MB
./stress_memory 2000  # 分配 2GB(如果物理内存不够,会触发 swap)

7. 与 HMM 的联系

页面回收机制是 HMM 的重要背景:

7.1 设备内存作为"高级 Swap"

HMM 允许将页面迁移到 GPU VRAM。从某种角度看,这类似于 swap------都是把页面从 CPU 内存移走:

方面 Swap HMM 设备迁移
目标 磁盘(慢) GPU VRAM(快)
PTE 编码 swap entry device_private_entry
回迁触发 CPU 访问 CPU 访问
回迁速度 毫秒级(磁盘 I/O) 微秒级(PCIe DMA)

7.2 Device Private Entry vs Swap Entry

c 复制代码
// 两种"页面不在 CPU 内存"的编码

// Swap entry:页面在磁盘
// ┌─────────────────────────────────────┐
// │ swap type │ swap offset │ flags │ 0 │
// └─────────────────────────────────────┘

// Device private entry:页面在设备内存
// ┌─────────────────────────────────────┐
// │ SWP_DEVICE_* │    PFN   │ flags │ 0 │
// └─────────────────────────────────────┘

7.3 为什么 HMM 页面也需要参与 LRU

ZONE_DEVICE 页面(设备内存的 struct page)也可以加入 LRU 链表:

  • 追踪活跃度:哪些设备页面是"热"的
  • 迁移决策:冷的设备页面可以考虑迁回 CPU
  • 统一框架:复用内核现有的回收基础设施

7.4 Memory Tiering:内存分层

现代系统有多种内存层次:

HMM 和内存分层(Memory Tiering)让内核可以在这些层次间透明迁移页面,优化性能和成本。


8. 本篇关键代码路径

文件 核心内容
mm/vmscan.c 页面回收核心(shrink_folio_list, kswapd, try_to_free_pages
mm/swap_state.c Swap cache 管理
mm/swapfile.c Swap 分区/文件管理
mm/swap.c LRU 链表操作
mm/rmap.c 反向映射(try_to_unmap
include/linux/mmzone.h enum lru_list、watermark 定义
include/linux/swap.h swap entry 类型定义

9. 下篇预告

页表遍历框架------walk_page_range() 的设计哲学

前几篇我们多次提到"遍历页表"------从虚拟地址找到 PTE,从 PTE 提取 PFN。内核为此提供了一个通用框架:walk_page_range()

下一篇我们将深入这个框架,理解 mm_walk_ops 回调表的设计,看内核如何让不同子系统(内存回收、/proc/pid/pagemap、HMM)复用同一套页表遍历逻辑。这个框架是 hmm_range_fault() 的基础。


10. 思考题

  1. 为什么 Linux 使用"双链表"(Active/Inactive)而不是简单的单一 LRU 链表?这种设计如何防止"扫描攻击"(一次大量读取把热页面挤出去)?

  2. 如果一个页面同时被父子进程映射(fork 后的 COW 页面),回收时需要解除几个 PTE 映射?rmap 如何找到所有这些映射?

  3. swappiness=0 是否意味着永远不会发生 swap?在什么情况下即使 swappiness=0 也会换出匿名页?

  4. 为什么 swap cache 很重要?如果没有 swap cache,共享页面的换入会有什么问题?

  5. HMM 的 device_private_entry 和 swap entry 都是 Present=0 的 PTE。内核如何区分它们?(提示:看 swap type 字段)


📚 关联阅读

相关推荐
DeeplyMind5 天前
附录 B:术语表
hmm
DeeplyMind2 个月前
HMM 学习路线目录规划
hmm·hmm_range
DeeplyMind2 个月前
linux中的HMM vs drm_pagemap 对比分析
hmm·drm_gpusvm·drm_pagemap·dev_pagemap·hmm_range
啊阿狸不会拉杆3 个月前
《计算机视觉:模型、学习和推理》第 11 章-链式模型和树模型
人工智能·学习·算法·机器学习·计算机视觉·hmm·链式模型
啊阿狸不会拉杆4 个月前
《机器学习导论》第15章- 隐马尔可夫模型(HMM)
人工智能·python·算法·机器学习·动态规划·hmm·隐马尔可夫模型
DeeplyMind4 个月前
02 - SVM相关的Linux内核基础
hmm·rocm·kfd·共享虚拟内存·amdgpu svm
大千AI助手7 个月前
高斯隐马尔可夫模型:原理与应用详解
人工智能·高斯·hmm·高斯隐马尔可夫模型·ghmm·马尔科夫模型·混合高斯模型
大千AI助手8 个月前
Viterbi解码算法:从理论到实践
算法·动态规划·hmm·隐马尔可夫·viterbi解码·viterbi·卷积码
uncle_ll1 年前
李宏毅NLP-6-seq2seq&HMM
人工智能·自然语言处理·nlp·李宏毅·hmm