当前位置:首页>Linux>Linux 页回收机制深度剖析: 从设计哲学到实战调试

Linux 页回收机制深度剖析: 从设计哲学到实战调试

  • 2026-10-21 13:27:41
Linux 页回收机制深度剖析: 从设计哲学到实战调试

Linux 页回收机制深度剖析: 从设计哲学到实战调试

引言: 当内存成为稀缺资源

想象一下你的书房 (系统内存) 里堆满了书籍 (内存页) . 起初空间充裕, 每本书都能平铺展开
Linux采用按需分页的内存管理模型, 这意味着程序可以申请远超物理内存的地址空间, 内核承诺 "需要时再给实际内存" . 这种承诺背后, 是页回收机制提供的保障——当物理内存不足时, 回收不常用的页面, 为新需求腾出空间. 下面让我们深入这个复杂而精巧的系统

第一部分: 页回收的触发条件——何时启动 "清理行动"

页回收不是持续进行的, 而是在特定条件下触发. 理解这些触发点, 是理解整个机制的基础

1.1 内存水线模型: Linux的 "水位预警系统"

Linux内核定义了三条关键内存水位线, 类似于水库的警戒水位:

// 内核源码示例: include/linux/mmzone.h
enum zone_watermarks {

    WMARK_MIN,  // 最低水位: 内存开始紧张
    WMARK_LOW,  // 低水位: 需要积极回收
    WMARK_HIGH, // 高水位: 内存充足
    NR_WMARK
};

这三条水位线将内存状态划分为四个区域:

现实比喻: 就像家庭用水, WMARK_HIGH是水塔满状态, WMARK_LOW是 "该省着点用" 的提醒, WMARK_MIN则是 "马上要停水" 的警报

1.2 触发回收的三种主要场景

  1. 1. 分配器触发: 当alloc_pages()在快速路径中失败时
  2. 2. kswapd守护进程: 后台监控并维持内存水位
  3. 3. 内存压力通知: cgroup内存子系统或用户空间触发
// 典型的内存分配压力路径 (简化) 
static
 struct page *get_page_from_freelist() {
    for_each_zone(zone) {
        if
 (zone_watermark_ok(zone, order, mark)) {
            return
 alloc_pages_from_zone(zone);
        }
    }
    // 水位检查失败, 触发回收

    return
 __alloc_pages_slowpath(order, gfp_mask);
}

第二部分: 核心概念详解——页回收的 "武器库"

2.1 LRU链表: 页面的 "热度排行榜"

LRU (Least Recently Used) 是页回收的核心数据结构. 内核维护多组LRU链表, 将页面按类型和活跃度分类:

关键数据结构:

// include/linux/mmzone.h
struct lruvec {

    // 五组LRU链表

struct list_head lists[NR_LRU_LISTS];

    // 相关统计信息

    unsigned
 long reclaim_stat[NR_VM_NODE_STAT_ITEMS];
};

// LRU链表类型枚举

enum lru_list {

    LRU_INACTIVE_ANON = 0,
    LRU_ACTIVE_ANON   = 1,
    LRU_INACTIVE_FILE = 2,
    LRU_ACTIVE_FILE   = 3,
    LRU_UNEVICTABLE   = 4,
    NR_LRU_LISTS
};

生活比喻: 把LRU链表想象成图书馆的书架. 新书和常用书放在前排 "活跃区"  (ACTIVE) , 久未借阅的书移到后排 "非活跃区"  (INACTIVE) . 图书管理员定期检查后排书架, 将长期无人问津的书籍存入仓库 (磁盘)

2.2 页面类型与回收策略差异

Linux区分两种主要页面类型, 回收策略截然不同:

页面类型
数据来源
回收动作
代价
生活比喻
文件页
文件系统缓存 (page cache)
直接丢弃 (clean) 或写回后丢弃 (dirty)
低
图书馆的复印件——没了可以重新复印
匿名页
堆、栈、共享内存等
写入交换分区 (swap out)
高
你的私人笔记——必须找个本子抄下来才能清空桌面
// 页面标志位包含类型信息
struct page {

    unsigned
 long flags;  // 包含PG_swapbacked, PG_dirty等
    // ...

};

// 检查页面是否为文件页

static
 inline int PageAnon(struct page *page) {
    return
 ((unsigned long)page->mapping & PAGE_MAPPING_FLAGS) == PAGE_MAPPING_ANON;
}

2.3 页面老化算法: 不只是简单的LRU

现代Linux使用双向时钟算法的变体, 称为页面老化. 页面不是简单的 "最近使用" , 而是有访问频率的概念

关键代码逻辑:

// mm/vmscan.c - 页面老化核心逻辑
static
 void shrink_active_list(unsigned long nr_to_scan,
                              struct
 lruvec *lruvec,
                              struct
 scan_control *sc)
 {
    // 遍历活跃链表

    while
 (!list_empty(&l_active)) {
        page = lru_to_page(&l_active);

        // 关键: 检查页面最近是否被访问

        if
 (page_referenced(page, 0, sc->target_mem_cgroup, &vm_flags)) {
            // 被访问过, 保持活跃

            list_add(&page->lru, &l_active);
        } else {
            // 未被访问, 移入非活跃

            list_add(&page->lru, &l_inactive);
        }
    }
}

第三部分: 页回收流程深度剖析

3.1 整体回收流程

3.2 扫描控制: 智能调整回收力度

scan_control结构是回收过程的"控制面板":

// mm/vmscan.c
struct scan_control {

    // 目标: 要回收多少页

    unsigned
 long nr_to_reclaim;

    // 优先级: 从12 (温和) 到0 (激进) 

    int
 priority;

    // 扫描比例: 匿名页 vs 文件页

    swappiness_t
 swappiness;

    // 是否可回写

    gfp_t
 gfp_mask;

    // 当前回收的memory cgroup

struct mem_cgroup *target_mem_cgroup;

};

优先级机制:

  • • 初始值: DEF_PRIORITY = 12
  • • 每次回收不足时: priority-- (更激进)
  • • 影响: nr_to_scan = LRU_size >> priority
  • • 优先级0时: 扫描整个LRU链表

3.3 匿名页与文件页的平衡: swappiness参数

/proc/sys/vm/swappiness (默认值60) 控制两者回收比例:

// 计算匿名页扫描比例
static
 unsigned long calc_anon_priority(unsigned long sc_priority, int swappiness) {
    // swappiness=0: 几乎不回收匿名页

    // swappiness=100: 积极回收匿名页

    unsigned
 long anon_prio = sc_priority * (100 - swappiness) / 100;
    return
 anon_prio;
}

实际策略:

  • • 高swappiness: 偏好swap out匿名页, 保留文件缓存
  • • 低swappiness: 偏好丢弃文件页, 避免swap

第四部分: 反向映射 (Reverse Mapping) ——回收的"寻址难题"

这是页回收中最复杂的部分!问题: 已知一个物理页, 如何找到所有映射它的虚拟地址?

4.1 为什么需要反向映射?

当回收一个共享页面 (如共享库的代码段) 时, 需要解除所有进程的映射. 没有反向映射, 我们不知道哪些PTE需要更新

4.2 三种反向映射实现

KSM页反向映射
文件页反向映射
匿名页反向映射
anon_vma结构
红黑树存储vma
快速查找所有映射
address_space
radix树索引页面
通过page->index定位
遍历所有vma
stable_node
链表存储rmap_item
合并相同页面
生成所有PTE列表
批量解除映射

核心数据结构:

// 匿名页反向映射
struct anon_vma {

struct rw_semaphore rwsem;

struct rb_root_cached rb_root;
  // 红黑树根
};

// 文件页反向映射

struct address_space {

struct inode *host;

struct radix_tree_root page_tree;
 // 基数树存储所有页面
};

// 通用的反向映射项

struct rmap_item {

struct page *page;

union {

struct anon_vma *anon_vma;
  // 匿名映射
struct address_space *mapping;
 // 文件映射
    };
};

4.3 解除映射操作

// mm/rmap.c - 关键的反向映射遍历
int
 try_to_unmap(struct page *page, enum ttu_flags flags) {
struct rmap_walk_control rwc =
 {
        .rmap_one = try_to_unmap_one,
        .arg = (void *)flags,
    };

    // 根据页面类型选择不同遍历方法

    if
 (PageAnon(page))
        rmap_walk_anon(page, &rwc);
    else

        rmap_walk_file(page, &rwc);

    return
 0;
}

// 实际的PTE清除

static
 int try_to_unmap_one(struct page *page, struct vm_area_struct *vma,
                           unsigned
 long address, void *arg)
 {
    pte_t
 *pte = get_pte(vma, address);
    if
 (pte_present(*pte) && pte_page(*pte) == page) {
        // 找到映射, 清除PTE

        pte_clear(vma->vm_mm, address, pte);
        tlb_remove_page(vma, address, page);
    }
    return
 SWAP_SUCCESS;
}

第五部分: 实战示例——编写触发页回收的测试程序

5.1 创建内存压力测试程序

// pressure.c - 创建内存压力触发页回收
#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <unistd.h>


#define CHUNK_SIZE (1024 * 1024)  // 1MB

#define TARGET_MB 2048  // 尝试分配2GB


int
 main() {
    char
 **buffers = NULL;
    int
 count = 0;
    size_t
 total_allocated = 0;

    printf
("开始内存压力测试, 目标: %d MB\n", TARGET_MB);
    printf
("当前swappiness: ");
    system("cat /proc/sys/vm/swappiness");

    // 分配内存直到触发回收

    while
 (total_allocated < TARGET_MB * 1024 * 1024) {
        char
 *buf = malloc(CHUNK_SIZE);
        if
 (!buf) {
            printf
("分配失败在 %ld MB\n", total_allocated / (1024*1024));
            break
;
        }

        // 触摸每个页面确保实际分配

        memset
(buf, 0, CHUNK_SIZE);

        // 保存指针以便后续操作

        buffers = realloc(buffers, (count + 1) * sizeof(char*));
        buffers[count++] = buf;
        total_allocated += CHUNK_SIZE;

        if
 (count % 100 == 0) {
            printf
("已分配: %ld MB\n", total_allocated / (1024*1024));
            sleep(1);  // 给kswapd反应时间
        }
    }

    printf
("\n测试完成, 保持内存占用...\n");
    printf
("观察/proc/meminfo和/proc/vmstat的变化\n");

    // 保持程序运行以便观察

    pause();

    // 清理 (实际上不会执行到这里) 

    for
 (int i = 0; i < count; i++) free(buffers[i]);
    free
(buffers);
    return
 0;
}

编译运行:

gcc -o pressure pressure.c
./pressure &
# 在另一个终端观察回收情况

5.2 观察回收行为的监控命令

# 1. 实时内存状态
watch -n 1 "cat /proc/meminfo | grep -E 'MemFree|Cached|Swap|Dirty|Writeback'"

# 2. 页回收统计

watch -n 1 "cat /proc/vmstat | grep -E 'pgscan|pgsteal|swap'"

# 3. 每个zone的水位

cat
 /proc/zoneinfo | grep -A5 -B5 min

# 4. kswapd活动

top -p $(pgrep kswapd)  # 观察kswapd的CPU使用

第六部分: 调试工具与技巧

6.1 关键tracepoint

# 启用页回收跟踪点
echo
 1 > /sys/kernel/debug/tracing/events/vmscan/enable

# 查看回收事件

cat
 /sys/kernel/debug/tracing/trace_pipe | grep vmscan

# 具体跟踪点包括: 

# vmscan:mm_vmscan_kswapd_wake

# vmscan:mm_vmscan_kswapd_sleep

# vmscan:mm_vmscan_shrink_slab_start

# vmscan:mm_vmscan_lru_shrink_inactive

6.2 手动触发回收

# 1. 手动释放页面缓存 (小心!生产环境慎用) 
echo
 1 > /proc/sys/vm/drop_caches  # 释放pagecache
echo
 2 > /proc/sys/vm/drop_caches  # 释放slab对象
echo
 3 > /proc/sys/vm/drop_caches  # 两者都释放

# 2. 调整回收参数进行测试

echo
 10 > /proc/sys/vm/swappiness  # 降低swap倾向
echo
 100 > /proc/sys/vm/vfs_cache_pressure  # 增加inode/dentry回收压力

6.3 使用ftrace深入分析

# 设置ftrace跟踪页回收函数
echo
 function > /sys/kernel/debug/tracing/current_tracer
echo
 shrink_inactive_list >> /sys/kernel/debug/tracing/set_ftrace_filter
echo
 1 > /sys/kernel/debug/tracing/tracing_on

# 运行压力测试

# ... 


echo
 0 > /sys/kernel/debug/tracing/tracing_on
cat
 /sys/kernel/debug/tracing/trace > /tmp/reclaim_trace.txt

6.4 页面类型分析工具

# 安装page-types工具 (需要kernel debug符号) 
git clone https://github.com/dwks/pagemap-tools
cd
 pagemap-tools
make

# 分析系统中页面分布

sudo
 ./page-types -b anon  # 只显示匿名页
sudo
 ./page-types -b file  # 只显示文件页
sudo
 ./page-types -b lru   # 显示LRU链表分布

第七部分: 高级主题与优化

7.1 Memory Cgroup的页回收

cgroup v1内存子系统有独立的水位线和回收机制:

# 设置cgroup内存限制
cgcreate -g memory:testgroup
echo
 100M > /sys/fs/cgroup/memory/testgroup/memory.limit_in_bytes

# cgroup的回收参数

echo
 60 > /sys/fs/cgroup/memory/testgroup/memory.swappiness
echo
 0 > /sys/fs/cgroup/memory/testgroup/memory.oom_control

7.2 工作集检测 (Working Set Detection)

现代内核使用refault距离算法检测工作集:

Refault距离 = 页面被回收后, 到再次访问时的页面缓存命中数

如果 Refault距离 < 活跃链表大小: 
    页面属于工作集, 应受保护
否则: 
    页面可安全回收

7.3 透明大页 (THP) 的回收挑战

大页 (2MB) 回收比普通页 (4KB) 更复杂:

  • • 需要拆分大页才能回收部分内存
  • • 反向映射更复杂
  • • 内核参数: /sys/kernel/mm/transparent_hugepage/khugepaged/defrag

7.4 内存压缩 (zswap/zram)

不是所有回收都要写磁盘, 现代内核支持:

  • • zswap: 压缩页面存入内存交换池
  • • zram: 基于内存的块设备, 透明压缩
# 检查zswap状态
cat
 /sys/kernel/debug/zswap/pool_total_size

# 配置zram

sudo
 modprobe zram num_devices=1
echo
 2G > /sys/block/zram0/disksize
sudo
 mkswap /dev/zram0
sudo
 swapon /dev/zram0

第八部分: 总结与最佳实践

8.1 Linux页回收设计哲学总结

  1. 1. 惰性策略: 不是预防性回收, 而是响应式回收
  2. 2. 分层回收: 从易到难 (文件页→匿名页)
  3. 3. 频率胜于近期: 访问频率比最近访问时间更重要
  4. 4. 工作集保护: 通过refault检测保护常用页面
  5. 5. 平衡艺术: 在回收开销和内存利用率间平衡

8.2 核心机制对比表

机制
触发条件
执行上下文
目标
激进程度
kswapd
后台定期检查, 低于WMARK_HIGH
内核线程
维持高水位
温和, 可中断
直接回收
快速分配路径失败
请求进程上下文
立即获得内存
激进, 可能阻塞
内存压力
cgroup超限或用户触发
多种上下文
缓解特定压力源
可配置
OOM Killer
所有回收尝试失败
独立内核线程
系统存活
最激进, 杀进程

8.3 性能调优建议

# 生产环境推荐配置 (根据负载调整) 
# /etc/sysctl.conf


# 数据库服务器 (少swap, 多缓存) 

vm.swappiness = 10
vm.dirty_ratio = 40
vm.dirty_background_ratio = 10
vm.vfs_cache_pressure = 50

# 应用服务器 (平衡型) 

vm.swappiness = 60
vm.dirty_ratio = 20
vm.dirty_background_ratio = 10
vm.vfs_cache_pressure = 100

# 桌面系统 (响应性优先) 

vm.swappiness = 30
vm.dirty_ratio = 10
vm.dirty_background_ratio = 5
vm.vfs_cache_pressure = 500

8.4 故障排查流程图

结语: Linux页回收的艺术

Linux页回收机制是一个渐进式完善的典范. 从最初的简单LRU, 到引入双链表策略, 再到工作集检测和内存压缩, 每一步演进都解决了实际部署中的痛点. 它不是完美的——没有一种算法能适应所有负载, 但它的可调节性和透明性使得管理员能够针对特定工作负载进行优化

理解页回收不仅是内核开发者的必修课, 也是系统管理员优化性能的关键. 当你看懂/proc/vmstat中的每个计数器, 理解kswapd的每次唤醒, 你就掌握了诊断内存问题的"第二双眼睛"

最新文章

随机文章