内存管理-77-kernel-internals系列-Memory专题-Caching & Reclaim-2-Page Reclaim


一、Page Reclaim 章节翻译

注: 翻译自 https://kernel-internals.org/mm/reclaim/

当内存不足时会发生什么


1. 什么是页面回收?

当系统空闲内存不足时,内核必须从现有用户手中回收页面。页面回收负责寻找可以被释放或写入交换空间(swap)的页面,从而为新的内存分配腾出空间。

内存压力 (Memory Pressure)
    │
    ▼
┌─────────────────────────┐
│  页面回收 (Page Reclaim) │
├─────────────────────────┤
│ - 文件页 (File pages)    │──> 写入脏页,丢弃干净页
│ - 匿名页 (Anonymous)     │──> 换出到 Swap
│ - Slab 缓存 (Slab)       │──> 收缩缓存
└─────────────────────────┘
    │
    ▼
 腾出空闲内存 (Free Pages Available)


2. 页面回收的两条路径

2.1 后台回收 (Background Reclaim - kswapd)

kswapd 是一个针对每个 NUMA 节点(per-node)专有的内核线程。当空闲内存降至低水位线(low watermark)以下时,它会在后台异步回收页面。

空闲内存降至低水位线 (low watermark) 以下
       │
       ▼
 唤醒 kswapd 线程
       │
       ▼
 持续回收页面,直到达到高水位线 (high watermark)
       │
       ▼
  kswapd 进入睡眠

• 核心特征:分配内存的进程不会被阻塞,kswapd 是异步工作的。


2.2 直接回收 (Direct Reclaim)

当 kswapd 的回收速度跟不上分配速度,导致空闲内存进一步降至最小水位线(min watermark)以下时,请求分配内存的进程必须自己亲自去回收页面。

 内存分配请求
       │
       ▼
空闲内存 < min 水位线? ─── 是 ──► 直接回收(进程发生阻塞 / Block)──► 重新尝试分配

• 核心特征:正在申请内存的进程会被动阻塞,直到释放出足够的页面。


3. 水位线 (Watermarks)

每个内存区域(zone)都有三个水位线来控制回收行为:

┌──────────────────┐
│ 高水位 (high) ───>│ 内存区域健康,无回收动作
├──────────────────┤
│ 低水位 (low) ────>│ 唤醒 kswapd,后台回收启动
├──────────────────┤
│ 最小水位 (min) ──>│ 直接回收,申请内存的进程参与回收
├──────────────────┤
│                  │ 进入 OOM 领域,开始杀进程
└──────────────────┘

这些水位线是根据 vm.min_free_kbytes 计算得出的:

# 查看当前各区域的水位线
cat /proc/zoneinfo | grep -E "min|low|high"

# 调整基准值(这将影响所有水位线)
sysctl vm.min_free_kbytes=65536


4. LRU 算法的演进 (LRU Evolution)

4.1 单链表 LRU 与流式传输问题

最初的页缓存 LRU 只是一个简单的单向链表:最近访问的页面放在链表头,最老的页面放在链表尾,释放内存时直接从尾部回收。这种方式对于工作集稳定的业务负载非常简单且有效。

痛点所在:当某个进程执行大文件的顺序读取(即“流式传输 / streaming”)时,它会一次性访问成千上万个页面,且每个页面仅访问一次。由于每个新页都会被插到 LRU 链表头,它们会将原本非常温热的活跃工作集页面一步步推向尾部并回收。当这个流式读取结束时,系统内宝贵的数据库缓存或应用程序的代码段页面早已被驱逐,取而代之的却是一堆再也不会被读取的文件数据。

这个设计缺陷在 Linux 历史的早期就被注意到了,并在 Linux 2.6 版本中引入了“双链表”的修复方案。

 

4.2 双链表 LRU(活跃 + 不活跃)(Two-list LRU (active + inactive))

双链表模型将 LRU 划分为活跃(active)和不活跃(inactive)两个链表:

• 新页面在首次发生缺页中断或被读取时,首先进入 不活跃链表。
• 如果该页面被第二次访问,它就会被晋升(Promote)到 活跃链表。
• 内存回收时仅从不活跃链表中挑选页面;活跃链表中的页面受到内核保护。

此时,流式读取虽然会填满不活跃链表,但由于这些页面不会被访问第二次,它们很快就会在不活跃链表尾部被回收,绝不会波及到活跃链表。真正核心的热数据则能安稳地留在活跃链表中存活下来。

这一机制高效运行了数十年,但也存在固有的局限性:仅凭一个信息位(“该页面自进入不活跃链表以来是否被访问过?”)来近似评估“这个页面有多热”,颗粒度实在太粗了。一个在过去一小时内被持续高频访问的页面,和一个刚刚被顺便访问了两次的页面,在活跃链表中看起来是完全一样的。

 

4.3 多代 LRU (Multi-Gen LRU / MGLRU, Linux 6.1, 2022)

来自 Google 的 Yu Zhao 在观察到搭载有限内存的 Android 设备在双链表模型下频繁发生“热页与温页之间的剧烈颠簸”后,设计了 MGLRU。其核心思想是:抛弃非黑即白的双链表分类,改用多个代表不同时间段的“代(Generations)”。

页面在触发缺页中断时会被赋予一个代号。内核会定期对这些代进行“老化(Aging)”处理:最近被访问过的页面会获得更新的代号;长久未被访问的页面则留在老一代中。内存回收时,永远最先从最老的一代中挑选页面。

这使内核能够掌握更细腻的页面年龄信息(例如第 8 代的页面明显比第 12 代更老),从而做出更合理的回收决策。此外,MGLRU 更加激进地利用了硬件的脏位(Dirty bits)和访问位(Access bits),无需每次访问都通过繁重的软件手段去追踪页面活跃度####。


5. LRU 链表

内核利用 LRU 链表来追踪页面的使用状态,页面会根据其访问模式在不同的链表间移动。

5.1 经典 LRU (双链表架构)

     活跃链表 (Active)                 不活跃链表 (Inactive)
   ┌───────────────────────┐       ┌───────────────────┐
   │ 热页 (Recent access)   │       │ 近期有访问         │
   │                       │ ────> │   (Recent access) │ ───► 执行内存回收
   └───────────────────────┘  降级  └───────────────────┘
     ▲                    (demote)                                     │
     │                     晋升 (promote)                               │
     └─────────────────────────────────────────────────────────────────┘


5.2 多代 LRU (MGLRU)

在 ``v6.1 内核``中引入的 MGLRU 彻底颠覆了双链表模型,通过``多代划分``实现了更高精度的老化追踪。

第 0 代 (最老) ──► 第 1 代 ──► ... ──► 第 N 代 (最新)
     │
     ▼
 执行回收

• 优势:更精准地识别冷热页面;大幅降低因扫描页表带来的 CPU 开销;显著提升系统在内存压力下的性能表现。

# 检查 MGLRU 是否启用(输出为十六进制掩码).0x0007 = 代表已完全启用 (0x0001 | 0x0002 | 0x0004)
cat /sys/kernel/mm/lru_gen/enabled

# 启用所有 MGLRU 特性(前提是编译内核时配置了 CONFIG_LRU_GEN)
echo 0x0007 > /sys/kernel/mm/lru_gen/enabled


6. 哪些内存可以被回收?

6.1 文件页 (File Pages / 页缓存)

指后端在磁盘上有对应文件的页面:
• 干净页 (Clean):可以直接就地释放和丢弃(因为后续需要时可以重新从磁盘读取)。
• 脏页 (Dirty):必须先将其中的修改同步写回到磁盘,随后才能释放。

6.2 匿名页 (Anonymous Pages)

指没有后端文件支撑的内存(例如进程的堆、栈数据):
• 有可用的 Swap 空间:先将数据换出(Swap out)到交换分区,随后释放该物理页面。
• 无可用的 Swap 空间:绝对无法直接回收,除非动用 OOM Killer 强行终止其所属的进程。

6.3 Slab 缓存 (Slab Caches)

内核对象缓存可以通过注册的 收缩器(shrinkers) 机制来进行空间压缩:

/* 各个子系统注册自己的收缩器 */
register_shrinker(&my_shrinker);

/* 内存回收期间会被内核回调的结构体 */
struct shrinker {
    unsigned long (*count_objects)(struct shrinker *, ...);
    unsigned long (*scan_objects)(struct shrinker *, ...);
};

例子:目录项缓存(dentry cache)、索引节点缓存(inode cache)以及各类 VFS 缓存。

# 查看各类收缩器的实时统计信息
cat /sys/kernel/debug/shrinker/*


7. Swappiness

vm.swappiness 用于微调内核在回收“文件页”与“匿名页”之间的平衡策略:

-------------------------------------------------------------------
取值	    表现行为
-------------------------------------------------------------------
0		极力避免匿名页换出,强烈倾向于直接回收文件缓存
60		系统的默认平衡值
100		极其激进地将匿名页换出到 Swap,以腾出更多物理内存
-------------------------------------------------------------------

配置接口:

# 查看当前系统的 swappiness 值
cat /proc/sys/vm/swappiness

# 临时调整(若想永久生效需修改 /etc/sysctl.conf)
sysctl vm.swappiness=10


8. OOM 杀手 (OOM Killer)

当系统所有的内存回收手段全部宣告失败,且物理内存彻底耗尽时,OOM (Out of Memory) 杀手就会被迫介入,强行终结某些进程以拯救陷入瘫痪的内核。

8.1 OOM 评分机制 (OOM Score)

每个进程都会被内核实时计算并赋予一个 OOM 分数(范围为 0 到 1000)。分数越高,代表该进程在发生内存耗尽时越优先被杀掉。

# View OOM score
cat /proc/<pid>/oom_score

# Adjust OOM priority (-1000 to 1000)
echo -500 > /proc/<pid>/oom_score_adj  # Less likely to be killed
echo 1000 > /proc/<pid>/oom_score_adj  # Kill this first


8.2 OOM 选择标准 (OOM Selection Criteria)

内核在选择要终止的进程时,主要考虑以下几点:
(1) 内存使用量(首要考量因素)。
(2) oom_score_adj 的人为调整值。
(3) 终止该进程是否能真正释放内存(例如检查其子进程以及共享页面的情况)。
(4) Root 权限进程会获得轻微的保护(在 v4.17 版本前拥有 3% 的分数减免优惠,之后该规则已被移除)。

您可以通过以下命令查看 OOM 事件日志或进行内核追踪:

# Watch OOM events
dmesg | grep -i "out of memory"
# Or via tracing
echo 1 > /sys/kernel/debug/tracing/events/oom/oom_score_adj_update/enable


9. 内存回收行为可调优参数 (Reclaim Behavior Tunables)

-------------------------------------------------------------------------------------------------------------------
系统控制参数 (Sysctl)		    功能描述 (Description)
-------------------------------------------------------------------------------------------------------------------
vm.min_free_kbytes			用于计算内存水位线(Watermark)的基础基准值。
vm.swappiness				匿名内存页(Anonymous page)与文件缓存页(File cache page)回收之间的平衡权重。
vm.vfs_cache_pressure		作用于目录项缓存(dentry cache)和索引节点缓存(inode cache)上的回收压力。
vm.dirty_ratio				触发阻塞应用程序继续写入操作的全系统脏页(Dirty pages)最大百分比上限。
vm.dirty_background_ratio	触发后台回写线程(Background writeback)开始向磁盘刷新数据时的脏页百分比阈值。
-------------------------------------------------------------------------------------------------------------------


10. 历史演进 (History)

起源 (Origins)
页面回收机制自 Linux 早期版本就已存在。最基础的 kswapd 机制是由 Stephen Tweedie 于 1996 年 1 月引入的(这在 vmscan.c 的源码头部注释中有明确记载),其历史甚至早于 Git 版本控制系统的记录。

反向映射 (rmap / Reverse Mapping, v2.5)
• 面临问题:为了回收一个页面,内核需要找到所有指向该页面的页表项(PTE)。在过去,这需要扫描系统中的所有页表,开销极大。
• 解决方案:引入反向映射机制 —— 让每个页面自身去追踪是哪些 PTE 映射了它。

拆分 LRU 链表 (Split LRU, v2.6.28, 2008)
• 提交记录:4f98a2fee8ac ("vmscan: split LRU lists into anon & file sets")
• 作者:Rik van Riel
• 注:在 lore.kernel.org 上,2009 年之前的 Linux 内核邮件列表(LKML)存档较为稀疏。该提交记录的注释完整记载了这一改动的核心原理。
• 改进内容:将原本单一的 LRU 链表拆分为独立的匿名页链表和文件页链表,从而让内核能够做出更合理的回收决策。

多代 LRU (MGLRU, v6.1, 2022)
• 提交记录:ac35a4902374 ("mm: multi-gen LRU: minimal implementation") | LKML
• 作者:Yu Zhao(赵予)
• 改进内容:引入多代 LRU 机制,显著提高了页面老化(Page aging)的精准度,并大幅降低了系统开销。


11. Try It Yourself

11.1 监控内存回收活动

# Real-time reclaim stats
watch -n 1 'cat /proc/vmstat | grep -E "pgscan|pgsteal|kswapd"' 

# Key metrics:
# pgscan_kswapd  - Pages scanned by kswapd
# pgscan_direct  - Pages scanned by direct reclaim
# pgsteal_*      - Pages successfully reclaimed


11.2 触发内存压力

# Consume memory to trigger reclaim
stress --vm 1 --vm-bytes 2G --vm-keep

# Watch kswapd wake up
watch -n 1 'ps aux | grep kswapd'


11.3 跟踪回收事件

# Enable reclaim tracing
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_vmscan_direct_reclaim_begin/enable
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_vmscan_direct_reclaim_end/enable

# Watch events
cat /sys/kernel/debug/tracing/trace_pipe


11.4 检查zone的内存压力

# Detailed zone info including watermarks
cat /proc/zoneinfo
# Quick check
cat /proc/buddyinfo


12. 常见问题 (Common Issues)

12.1 直接回收停顿 (Direct Reclaim Stalls)

进程被阻塞在直接回收(Direct reclaim)流程中,从而导致系统出现明显的延迟毛刺(Latency spikes)。

• 排查方法:检查 /proc/vmstat 中的 allocstall_* 计数器。
• 解决方案:增大 vm.min_free_kbytes 的值。如果系统没有配置交换分区(Swap),请添加 Swap。降低系统整体的内存压力。

 

12.2 kswapd CPU 使用率过高 (kswapd CPU Usage)

kswapd 后台线程消耗了过多的 CPU 资源。
• 排查方法:使用 top 或 perf top 工具进行观测。
• 引发原因:系统剩余的空闲内存过少。业务负载在持续且频繁地产生脏页(Dirty pages)。系统陷入了交换颠簸/抖动(Swap thrashing)。

 

12.3 尽管有空闲内存仍触发 OOM 杀手

系统明明还存在空闲内存,却依然触发了 OOM killer 将进程强制终止。
• 排查方法:检查 /proc/buddyinfo 和 /proc/zoneinfo。
• 引发原因:物理内存虽然充足,但它们处于错误的内存区域(Zone)中,或者内存碎片化极其严重,导致无法分配出连续的大块内存。


13. 延伸阅读 (Further reading)

• mm/vmscan.c —— 核心内存回收引擎:包含 shrink_node()、shrink_lruvec()、shrink_folio_list()、balance_pgdat()(kswapd 主循环)以及 try_to_free_pages()(直接回收的入口点)。

• Documentation/admin-guide/mm/multigen_lru.rst —— 多代 LRU(MGLRU)架构文档:介绍了代系模型、页表遍历优化,以及用于平衡扫描开销的内置 PID 控制器。

• Documentation/mm/balance.rst —— 高层设计文档:阐述了内核如何在不同的回收参与者、内存区域(Zones)和 NUMA 节点之间平衡内存。

• LWN: Multi-generational LRU —— 详述了 MGLRU 的设计目标、它相比于传统双链表模型的改进原理,以及它为何能降低混合业务负载下的 CPU 开销。

• LWN: Better active/inactive list balancing —— 介绍了传统双链表 LRU 模型存在的问题,正是这些问题推动了拆分 LRU(split-LRU)以及后续 MGLRU 的研发。

• reclaim-throttling(回收节流) —— 阐述当内存回收速度跟不上内存分配速度时的处理机制:包含 reclaim_throttle() 函数、memory.high 的按比例延迟机制,以及 kswapd 如何与直接回收者进行协同。####

• shrinker(收缩器) —— 介绍了内核各子系统(如目录项/索引节点缓存、驱动程序对象池)如何注册回调函数,以便内存回收机制在回收页缓存和匿名页的同时,也能对这些缓存进行收缩。

• memcg(内存控制组) —— 介绍了由 memory.high 和 memory.max 触发的单 cgroup 级内存回收,以及 memory.reclaim 这一主动回收接口。

 

posted on 2026-10-08 14:52  Hello-World3  阅读(2)  评论(0)    收藏  举报

导航