2. 缺页异常概述
缺页异常(Page Fault)是虚拟内存管理的核心机制。当 CPU 试图访问一个虚拟地址,而该地址对应的物理页框尚未加载到物理内存中,或当前页表项(PTE)的权限检查不通过时,MMU 会触发缺页异常,由内核缺页异常处理程序(do_page_fault)负责解析并修复该异常。
性能视角: 缺页异常是内存子系统中最昂贵的路径之一。一次缺页异常可能涉及磁盘 I/O、内存回收、TLB 刷写等操作,延迟从微秒级到毫秒级不等。理解其分类与触发流程,是进行性能优化的前提。
2.1 缺页异常定义
缺页异常是 x86/ARM 架构下的一种内存异常(Exception),由 MMU 在地址翻译过程中检测到以下情况时触发:
- 页不存在: 虚拟地址对应的页表项(PTE)的 Present 位为 0。
- 权限违例: 访问类型(读/写/执行)与 PTE 中的权限位(R/W、NX、User/Supervisor)不匹配。
- 保留位违规: PTE 中某些保留位被置位,通常表示页表结构损坏。
缺页异常处理完成后,CPU 会重新执行触发异常的指令,此时翻译应能成功完成。
关键指标: 缺页异常率(Page Fault Rate) = 缺页次数 / 总内存访问次数。在性能分析中,通常关注 major fault(主缺页)的占比,因为它直接关联磁盘 I/O 延迟。
2.2 缺页异常分类
Linux 内核将缺页异常分为两大类:硬缺页(Hard Page Fault / Major Fault) 和 软缺页(Soft Page Fault / Minor Fault)。两者的核心区别在于是否需要访问磁盘 I/O。
| 分类 | 触发条件 | I/O 开销 | 延迟量级 | 典型场景 |
|---|---|---|---|---|
| 硬缺页(Major Fault) | 页不在物理内存中,且不在任何进程的 VMA 中,需要从磁盘 swap 分区或文件映射中读取 | 有(磁盘 I/O) | 毫秒 ~ 数十毫秒 | 内存不足导致 swap 换入、首次读取文件映射页 |
| 软缺页(Minor Fault) | 页不在当前进程的页表中,但物理页框已在内存中(如共享内存、COW 写时复制、页面缓存命中) | 无(仅内存操作) | 微秒级 | fork() 后写时复制、共享库加载、匿名页首次访问 |
优化警示: 硬缺页是性能杀手。在数据库、Web 服务器等内存密集型应用中,应通过
vmstat 的 si(swap in)和 so(swap out)字段监控硬缺页频率。如果 si/so 持续非零,说明物理内存严重不足,需要增加内存或调整 vm.swappiness。
2.3 缺页异常触发流程
缺页异常从 CPU 触发到内核处理完成,经历以下关键阶段。下面用 SVG 流程图展示完整路径:
2.4 缺页异常处理的核心函数调用链
从 CPU 异常向量表到最终完成页框分配,内核的调用路径如下:
// x86-64 架构下的缺页异常处理路径(简化)
// 1. CPU 异常入口
entry_64.S → page_fault → do_page_fault()
// 2. 内核 C 语言处理
do_page_fault(struct pt_regs *regs, unsigned long error_code)
├── find_vma(current->mm, address) // 查找地址所属 VMA
│ └── 如果未找到 → __do_kernel_fault() → SIGSEGV
├── handle_mm_fault(vma, address, flags) // 核心缺页处理
│ ├── __handle_mm_fault(vma, address, flags)
│ │ ├── pgd_offset() / p4d_offset() / pud_offset() / pmd_offset()
│ │ ├── handle_pte_fault(&pte, ...)
│ │ │ ├── do_anonymous_page() // 匿名页缺页(软缺页)
│ │ │ ├── do_fault() // 文件映射缺页
│ │ │ │ ├── do_read_fault() // 读缺页
│ │ │ │ ├── do_cow_fault() // 写时复制缺页
│ │ │ │ └── do_shared_fault() // 共享映射写缺页
│ │ │ └── do_swap_page() // swap 换入(硬缺页)
│ └── mm_account_fault() // 统计缺页次数
└── user_mode(regs) ? 返回用户态 : 返回内核态
性能热点函数: 在缺页异常路径中,
handle_pte_fault() 是最大的性能分支点。对于硬缺页,do_swap_page() 和 do_read_fault() 中的 __lock_page_or_retry() 和 wait_on_page_bit() 是主要锁竞争和等待点。对于软缺页,do_anonymous_page() 中的 pte_alloc() 页表分配可能触发内存回收,间接引入延迟。
2.5 缺页异常的性能影响矩阵
不同类型的缺页异常对系统性能的影响差异巨大。下表总结了典型场景下的延迟和资源消耗:
| 缺页类型 | 典型延迟 | CPU 消耗 | 锁竞争风险 | I/O 等待 | 优化方向 |
|---|---|---|---|---|---|
| 匿名页软缺页 | 1~5 µs | 低 | 低(页表锁) | 无 | 预分配、透明大页 |
| 文件映射软缺页(缓存命中) | 2~10 µs | 中 | 中(页面缓存锁) | 无 | 文件预读、大页 |
| 写时复制(COW)软缺页 | 3~15 µs | 中 | 中(RMAP 锁) | 无 | KSM、大页 COW 优化 |
| swap 换入硬缺页 | 1~10 ms | 高 | 高(swap 锁、LRU 锁) | 有(磁盘 I/O) | 减少 swap、SSD、zswap |
| 文件映射硬缺页(磁盘读取) | 2~20 ms | 高 | 中(页面缓存锁) | 有(磁盘 I/O) | 异步预读、io_uring |
性能分析工具: 使用
perf stat -e page-faults,minor-faults,major-faults 快速查看进程的缺页分布。使用 trace-cmd record -e page_fault:* 可以追踪每次缺页异常的详细信息,包括地址、类型和耗时。
2.6 缺页异常与 TLB 的关系
缺页异常处理完成后,内核会更新进程的页表,但 TLB(Translation Lookaside Buffer)中可能仍然缓存了旧的页表项。因此,缺页异常处理程序必须确保 TLB 的一致性:
- 本地 TLB 刷写: 对于单进程缺页,使用
__flush_tlb_one()刷写单个虚拟地址对应的 TLB 条目。 - 全局 TLB 刷写: 对于跨进程的页表修改(如 munmap),使用
flush_tlb_mm()或flush_tlb_all()。 - TLB 缺失与缺页的叠加: 如果 TLB 缺失后页表遍历也失败,则触发缺页异常。缺页处理完成后,TLB 缺失仍然存在,需要重新遍历页表并填充 TLB。
优化陷阱: 频繁的缺页异常会导致 TLB 抖动。每次缺页处理都会刷写至少一个 TLB 条目,如果缺页率过高,TLB 的命中率会急剧下降,形成“缺页 → TLB 刷写 → TLB 缺失 → 页表遍历 → 再次缺页”的恶性循环。这是内存性能优化的核心挑战之一。