Linux 页回收机制深度剖析: 从设计哲学到实战调试
引言: 当内存成为稀缺资源
想象一下你的书房 (系统内存) 里堆满了书籍 (内存页) . 起初空间充裕, 每本书都能平铺展开
Linux采用按需分页的内存管理模型, 这意味着程序可以申请远超物理内存的地址空间, 内核承诺 "需要时再给实际内存" . 这种承诺背后, 是页回收机制提供的保障------当物理内存不足时, 回收不常用的页面, 为新需求腾出空间. 下面让我们深入这个复杂而精巧的系统
第一部分: 页回收的触发条件------何时启动 "清理行动"
页回收不是持续进行的, 而是在特定条件下触发. 理解这些触发点, 是理解整个机制的基础
1.1 内存水线模型: Linux的 "水位预警系统"
Linux内核定义了三条关键内存水位线, 类似于水库的警戒水位:
c
// 内核源码示例: include/linux/mmzone.h
enum zone_watermarks {
WMARK_MIN, // 最低水位: 内存开始紧张
WMARK_LOW, // 低水位: 需要积极回收
WMARK_HIGH, // 高水位: 内存充足
NR_WMARK
};
这三条水位线将内存状态划分为四个区域:
低于WMARK_HIGH
介于WMARK_LOW与HIGH之间
介于WMARK_MIN与LOW之间
低于WMARK_MIN
内存使用量
与水位线比较
充足区域
正常区域
压力区域
紧急区域
kswapd休眠
轻微压力
kswapd主动回收
直接回收/内存不足终止
现实比喻: 就像家庭用水, WMARK_HIGH是水塔满状态, WMARK_LOW是 "该省着点用" 的提醒, WMARK_MIN则是 "马上要停水" 的警报
1.2 触发回收的三种主要场景
- 分配器触发 : 当
alloc_pages()在快速路径中失败时 - kswapd守护进程: 后台监控并维持内存水位
- 内存压力通知: cgroup内存子系统或用户空间触发
c
// 典型的内存分配压力路径 (简化)
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链表, 将页面按类型和活跃度分类:
每个内存节点(NODE)的LRU结构
非活跃链表 (冷数据)
活跃链表 (热数据)
老化/访问频率低
老化/访问频率低
再次被访问
再次被访问
回收候选
回收候选
不可回收链表
UNEVICTABLE 如mlock页面
ACTIVE_ANON 活跃匿名页
ACTIVE_FILE 活跃文件页
INACTIVE_ANON 非活跃匿名页
INACTIVE_FILE 非活跃文件页
页面回收
关键数据结构:
c
// 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) | 高 | 你的私人笔记------必须找个本子抄下来才能清空桌面 |
c
// 页面标志位包含类型信息
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使用双向时钟算法 的变体, 称为页面老化 . 页面不是简单的 "最近使用" , 而是有访问频率的概念
Refault检测机制
否
页面被回收
之后再次访问
在活跃链表中?
refault事件
提高类似页面保护
页面活跃度评分
是
否
页面
最近被访问?
增加refault距离
逐渐衰减
保持在活跃链表
移向非活跃链表
关键代码逻辑:
c
// 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 整体回收流程
直接回收流程
kswapd循环
是
否
是
是
否
后台回收
同步回收
shrink_node核心
遍历所有可回收的memcg
计算扫描优先级
根据优先级决定扫描深度
平衡匿名页和文件页回收
shrink_active_list收缩活跃链表
shrink_inactive_list收缩非活跃链表
内存分配失败/水位不足
触发方式
检查各zone水位
低于WMARK_HIGH?
计算需要回收的页面数
执行shrink_node
达到高水位?
休眠直到下次检查
调用者进程上下文执行
设置scan_control参数
shrink_node收缩内存节点
回收足够页面?
返回成功
可能触发OOM
3.2 扫描控制: 智能调整回收力度
scan_control结构是回收过程的"控制面板":
c
// 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) 控制两者回收比例:
c
// 计算匿名页扫描比例
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列表
批量解除映射
核心数据结构:
c
// 匿名页反向映射
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 解除映射操作
c
// 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 创建内存压力测试程序
c
// 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;
}
编译运行:
bash
gcc -o pressure pressure.c
./pressure &
# 在另一个终端观察回收情况
5.2 观察回收行为的监控命令
bash
# 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
bash
# 启用页回收跟踪点
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 手动触发回收
bash
# 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深入分析
bash
# 设置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 页面类型分析工具
bash
# 安装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内存子系统有独立的水位线和回收机制:
bash
# 设置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: 基于内存的块设备, 透明压缩
bash
# 检查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页回收设计哲学总结
- 惰性策略: 不是预防性回收, 而是响应式回收
- 分层回收: 从易到难 (文件页→匿名页)
- 频率胜于近期: 访问频率比最近访问时间更重要
- 工作集保护: 通过refault检测保护常用页面
- 平衡艺术: 在回收开销和内存利用率间平衡
8.2 核心机制对比表
| 机制 | 触发条件 | 执行上下文 | 目标 | 激进程度 |
|---|---|---|---|---|
| kswapd | 后台定期检查, 低于WMARK_HIGH | 内核线程 | 维持高水位 | 温和, 可中断 |
| 直接回收 | 快速分配路径失败 | 请求进程上下文 | 立即获得内存 | 激进, 可能阻塞 |
| 内存压力 | cgroup超限或用户触发 | 多种上下文 | 缓解特定压力源 | 可配置 |
| OOM Killer | 所有回收尝试失败 | 独立内核线程 | 系统存活 | 最激进, 杀进程 |
8.3 性能调优建议
bash
# 生产环境推荐配置 (根据负载调整)
# /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 故障排查流程图
高IO等待
kswapd高CPU
直接回收
是
否
内存不足问题
检查症状
症状表现
swap活跃
持续回收
分配延迟
检查/proc/vmstat中swapin/swapout
检查/proc/zoneinfo水位
检查进程直接回收统计
优化swappiness 检查内存泄漏 增加物理内存
调整min_free_kbytes 减少内存占用 优化工作集
优化应用内存使用 调整GFP标志 预分配大块内存
监控调整效果
问题解决?
完成
深入调试
使用tracepoint跟踪
分析页面类型分布
检查cgroup限制
可能需要内核专家协助
结语: Linux页回收的艺术
Linux页回收机制是一个渐进式完善的典范. 从最初的简单LRU, 到引入双链表策略, 再到工作集检测和内存压缩, 每一步演进都解决了实际部署中的痛点. 它不是完美的------没有一种算法能适应所有负载, 但它的可调节性和透明性使得管理员能够针对特定工作负载进行优化
理解页回收不仅是内核开发者的必修课, 也是系统管理员优化性能的关键. 当你看懂/proc/vmstat中的每个计数器, 理解kswapd的每次唤醒, 你就掌握了诊断内存问题的"第二双眼睛"