在游戏社区论坛上,一个反复出现的问题困扰着许多玩家:明明电脑配置足够,为什么游戏会莫名其妙地"卡顿"?有人用LatencyMon工具分析后发现"Hard Page Faults"数值异常,有人尝试关闭虚拟内存后问题消失,有人升级到SSD后卡顿明显改善。

这些看似零散的现象,背后都指向同一个技术概念——缺页中断(Page Fault)。它是现代操作系统虚拟内存机制的核心,也是理解程序"莫名卡顿"的关键。

一个"错误"的诞生:从硬件陷阱到操作系统魔法

“Page Fault"这个名字本身就充满误导性。在很多人看来,“Fault"意味着某种错误或异常,是需要修复的问题。但实际上,缺页中断是虚拟内存系统正常运转的基础机制,大多数情况下它不是Bug,而是Feature。

当CPU尝试访问一个虚拟地址时,硬件会首先检查页表项(Page Table Entry)中的Present位。如果这个位是0,意味着该虚拟页当前没有映射到物理内存——可能是从未加载,也可能是被交换到了磁盘。此时,CPU会触发一个异常,将控制权转交给操作系统的缺页中断处理程序。

这个硬件异常的产生和传递过程本身只需要纳秒级别的时间。真正的开销在于操作系统的后续处理——它必须找到合适的物理页框、可能需要从磁盘读取数据、更新页表、刷新TLB。这些操作的时间跨度,从几微秒到几十毫秒不等,取决于缺页的类型和系统的状态。

软缺页与硬缺页:微秒与毫秒的分水岭

缺页中断分为两种截然不同的类型,它们的性能影响相差可达1000倍以上。

次缺页中断(Minor/Soft Page Fault)

当请求的页面实际上已经在物理内存中,只是页表映射尚未建立时,就会发生次缺页中断。常见场景包括:

  • Copy-on-Writefork()系统调用后,父子进程共享相同的物理页,页表项被标记为只读。当任一进程尝试写入时,触发次缺页中断,内核分配新页并复制数据。
  • 需求零页:通过malloc()mmap()分配的匿名内存,首次访问时触发次缺页中断,内核分配一个已经清零的物理页。
  • 页面共享:多个进程映射同一个共享库或文件,后续进程访问时只需建立映射,无需重新加载。

根据System Overflow的技术文档,次缺页中断的延迟通常在1-10微秒范围内。Erik Rigtorp在其实时系统指南中提到,次缺页中断本质上只是"更新页表项的指针”,内核不需要执行任何磁盘I/O。

但是,次缺页中断并非完全无害。2014年,Arseny Kapoulkine(zeux.io)进行了一项详细的性能分析,发现次缺页中断的处理在Windows上是串行化的——当多个线程同时触发缺页中断时,它们的处理会被内核串行执行。在8线程测试中,额外500MB的内存映射导致性能下降约84毫秒,折算下来每个缺页约680纳秒。这个数字看起来很小,但当处理本身已经高度优化(达到6GB/s的数据吞吐量)时,软缺页中断的累积效应就变得不可忽视。

主缺页中断(Major/Hard Page Fault)

当请求的页面不在物理内存中,必须从磁盘读取时,就会发生主缺页中断。这是真正的性能杀手。

根据Wikipedia引用的数据:

  • 内存访问延迟:约70纳秒
  • SSD读取延迟:约30微秒(高端NVMe SSD)
  • HDD读取延迟:5-10毫秒(包括寻道时间和旋转延迟)

这意味着,主缺页中断相对于内存访问的开销是:

  • SSD系统:约400-1000倍
  • HDD系统:约70,000-140,000倍

更直观地说,如果内存访问是1秒,那么SSD上的主缺页中断相当于6-17分钟,HDD上则相当于19-39小时

传统教科书(如UIC的课程笔记)常引用"缺页中断需要8毫秒"的经验值,这是基于早期HDD的典型参数:平均寻道时间5毫秒、旋转延迟3毫秒、传输时间可忽略。在SSD时代,这个数字大幅改善,但仍然存在数量级的差距。

从Atlas到现代:六十年的虚拟内存演进

1962年,曼彻斯特大学的Tom Kilburn团队完成了Atlas计算机的设计。这台机器引入了一个革命性的概念——“One-Level Storage”(一级存储),后来被称为虚拟内存。Atlas的24位地址空间可以寻址200万个48位字,但物理内存只有16,384个字。通过页面置换机制,Atlas让程序员不必关心数据的实际存储位置——这是虚拟内存的诞生。

然而,虚拟内存的实现很快遇到了一个令人困惑的现象:某些系统会在负载增加时突然性能崩溃,CPU利用率降到接近零,磁盘疯狂读写。1968年,Peter J. Denning在ACM上发表了两篇开创性论文——《The Working Set Model for Program Behavior》和《Thrashing: Its Causes and Prevention》,系统性地解释了这个现象。

Denning提出的工作集模型(Working Set Model)指出:每个进程在任意时刻都有一个"工作集”——即在过去Δ时间窗口内访问过的页面集合。如果系统分配给进程的物理页框数少于其工作集大小,进程就会频繁产生缺页中断。当多个进程同时处于这种状态时,系统会陷入"颠簸"(Thrashing)——大部分时间花在页面换入换出上,实际工作时间极少。

graph LR
    A[进程请求内存] --> B{工作集是否全部在内存?}
    B -->|是| C[正常执行]
    B -->|否| D[频繁缺页中断]
    D --> E[CPU利用率下降]
    E --> F[系统调度更多进程]
    F --> G[内存更加紧张]
    G --> D
    G --> H[颠簸崩溃]

这个模型的核心洞察是:颠簸是一种正反馈崩溃。当系统内存不足时,进程开始频繁缺页;CPU利用率下降导致调度器认为系统"空闲",于是调度更多进程;新进程进一步加剧内存压力,导致更多缺页。最终,系统陷入磁盘I/O的死循环。

现代操作系统通过多种机制来预防和缓解颠簸:

  • 工作集监控:Linux内核通过/proc/[pid]/stat暴露进程的缺页统计,ps -o majflt,minflt可以查看
  • 页面置换算法:从简单的FIFO到LRU近似(Clock算法),再到现代的Double Clock和LRU-2
  • OOM Killer:当内存压力过大时,Linux会选择性地终止进程

缺页中断的内核旅程:从CPU异常到磁盘I/O

理解缺页中断的性能开销,需要深入其处理流程。以Linux x86_64为例:

硬件异常触发

当CPU访问一个Present位为0的页表项时,触发14号异常(#PF)。CPU自动保存当前状态到内核栈,包括错误码(Error Code)和导致异常的线性地址(CR2寄存器)。

入口处理

arch/x86/mm/fault.c中的exc_page_fault()是异常入口。它首先检查异常发生的上下文——用户态还是内核态、读取还是写入、是否在访问指令获取阶段。这些信息决定了后续处理路径。

VMA查找

内核需要在进程的mm_struct中查找包含该虚拟地址的vm_area_struct(VMA)。这是一个红黑树查找操作,时间复杂度为O(log n)。如果找不到对应的VMA,则说明访问非法,向进程发送SIGSEGV信号。

页面分配与加载

对于有效的VMA,内核调用handle_mm_fault(),这是架构无关的核心处理函数。它会:

  1. 检查页面是否在交换缓存或页面缓存中——如果是,直接建立映射(次缺页)
  2. 分配新的物理页框——可能触发内存回收
  3. 从磁盘读取数据——对于文件映射,调用文件系统的readpage;对于匿名内存,清零页面;对于交换页,调用swapin
  4. 更新页表项——设置Present位、权限位、脏位等
  5. 刷新TLB——确保后续访问使用新的映射

用户态恢复

异常处理完成,CPU恢复用户态执行。对于主缺页中断,导致缺页的线程在此期间一直处于阻塞状态。

这个流程中的每一个步骤都有其开销。对于次缺页中断,主要开销在于内核态/用户态切换、VMA查找、页表更新和TLB刷新——典型的总耗时在微秒级别。对于主缺页中断,磁盘I/O完全主导了延迟。

颠簸的数学本质:当局部性遇上有限内存

Denning的工作集模型揭示了颠簸的本质:程序的局部性与物理内存容量之间的矛盾

程序的内存访问具有时间和空间局部性。时间局部性意味着最近访问过的数据很可能再次被访问;空间局部性意味着相邻的数据很可能一起被访问。工作集正是时间局部性的量化描述——它代表了一个进程在任意时刻"活跃"的那部分内存。

当系统的物理内存不足以容纳所有活跃进程的工作集时,颠簸就不可避免。这不是算法优化的问题,而是基本的资源约束。

工作集大小(WSS)与缺页率(PFR)的关系呈现出一个典型的"U型曲线":

内存分配 缺页行为
WSS完全在内存 几乎没有缺页,偶尔有次缺页
接近WSS边界 缺页率逐渐上升,主要是次缺页
低于WSS 缺页率急剧上升,主缺页开始出现
远低于WSS 系统完全颠簸,CPU利用率接近零

这个关系解释了一个常见的运维困境:增加内存对性能的提升是非线性的。当内存从"不足"增加到"刚好够用"时,性能会有质的飞跃;但当内存已经足够时,再增加内存几乎没有收益。

TLB缺失与缺页中断:两个层面的地址翻译问题

讨论缺页中断时,容易与另一个概念混淆——TLB缺失(TLB Miss)。两者都与地址翻译有关,但发生在不同的层面。

TLB(Translation Lookaside Buffer)是CPU内部的地址翻译缓存。它存储了最近使用的虚拟页号到物理页框号的映射。TLB的大小非常有限——现代处理器的L1 DTLB通常只有64个条目,L2 STLB可能有1024-4096个条目。

当CPU访问一个虚拟地址时,首先在TLB中查找。如果命中(TLB Hit),地址翻译在纳秒级完成。如果未命中(TLB Miss),硬件需要执行页表遍历(Page Table Walk)——从内存中读取页表项来获取物理地址。

对于x86_64的四级页表,一次完整的页表遍历需要4次内存访问(读取PML4、PUD、PMD、PT四个级别的页表项)。每次内存访问的延迟约为70-100纳秒(取决于缓存命中情况),所以一次TLB Miss的代价大约是数百纳秒

而缺页中断发生时,页表项的Present位为0,意味着TLB中根本不会有这个映射。CPU必须转交给操作系统处理,整个流程的代价是微秒到毫秒级别

事件 延迟 原因
TLB Hit <1 ns 纯硬件操作,缓存命中
TLB Miss ~100-500 ns 硬件页表遍历,多次内存访问
次缺页中断 ~1-10 μs 内核态处理,页表更新,TLB刷新
主缺页中断(SSD) ~30-100 μs 磁盘读取+内核处理
主缺页中断(HDD) ~5-10 ms 机械延迟+磁盘读取+内核处理

这就是为什么大页(Huge Pages)技术可以显著改善大内存工作负载的性能:将默认的4KB页改为2MB或1GB页,可以将TLB条目覆盖的内存范围扩大512倍或262144倍,从而大幅减少TLB Miss和缺页中断的次数。

实时系统的生存法则:与缺页中断的战争

对于实时系统,缺页中断是不可接受的。高频交易系统要求端到端延迟控制在微秒级别,一次主缺页中断可能意味着数万美元的损失。

实时系统采用多种策略来消除缺页中断的影响:

内存锁定:mlock()

mlock()系统调用可以将指定的内存区域锁定在物理内存中,禁止内核将其换出。这是最直接的防护手段。

// 锁定整个进程的地址空间
mlockall(MCL_CURRENT | MCL_FUTURE);

// 或者锁定特定区域
mlock(buffer, buffer_size);

MCL_CURRENT锁定当前已映射的所有页面,MCL_FUTURE锁定未来分配的所有页面。后者对于动态内存分配尤其重要。

预缺页:MAP_POPULATE

mmap()MAP_POPULATE标志会在映射建立时立即分配并填充所有页面,而不是等待首次访问时按需分配。这相当于将缺页中断的开销从运行时转移到了初始化阶段。

void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE,
                  -1, 0);

预热:显式访问

对于无法使用MAP_POPULATE的场景(如已分配的堆内存),可以在初始化阶段显式访问每个页面:

// 预热堆内存
for (size_t offset = 0; offset < heap_size; offset += page_size) {
    ((volatile char *)heap_start)[offset] = 0;
}

volatile关键字防止编译器优化掉这个看似无用的写入操作。

禁用交换

对于实时系统,完全禁用交换分区是最安全的做法:

sudo swapoff -a

或者在内核启动参数中设置swapaccount=0

关闭干扰特性

现代Linux内核有一些特性会引入不可预测的延迟,需要禁用:

  • 透明大页(THP):后台的khugepaged线程会尝试合并小页为大页,这个过程需要锁定页表并可能导致延迟尖峰
  • 内核同页合并(KSM):后台线程会扫描内存寻找可合并的相同页面,同样需要锁定
  • NUMA自动平衡:会迁移页面到不同的NUMA节点,导致缺页中断和TLB刷新

Erik Rigtorp的实时系统指南详细列出了这些优化措施及其原理。

监控与诊断:找出缺页中断的源头

在生产环境中诊断缺页中断问题,需要正确的工具和方法。

系统级监控

# 查看系统整体缺页统计
cat /proc/vmstat | grep -E "pgfault|pgmajfault"

# pgfault: 所有缺页中断的累计次数
# pgmajfault: 主缺页中断的累计次数

进程级监控

# 查看进程的缺页统计
ps -o pid,majflt,minflt,comm -p <pid>

# 或使用 /proc
cat /proc/<pid>/stat | awk '{print "majflt:", $12, "minflt:", $10}'

实时监控

# 使用 pidstat 实时监控
pidstat -r 1 -p <pid>

# 使用 perf 追踪缺页中断
perf record -e faults -a -- sleep 10
perf report

Windows系统

Windows用户可以使用LatencyMon工具,它会监控系统的ISR(中断服务程序)、DPC(延迟过程调用)执行时间和页面错误,特别适合诊断音频卡顿和游戏卡顿问题。

缺页中断的未来:NVM与内存分层

随着非易失性内存(NVM)和存储级内存(SCM)技术的发展,传统的内存-磁盘层次结构正在发生变化。Intel Optane DC Persistent Memory等设备提供了介于DRAM和SSD之间的性能特征——比DRAM慢一个数量级,但比SSD快一个数量级,同时具有持久性。

这为操作系统的内存管理带来了新的可能性:

  • 直接映射:将NVM作为内存的一部分直接映射到进程地址空间,减少甚至消除主缺页中断
  • 分层内存:内核自动将热页面保持在DRAM,冷页面迁移到NVM
  • 持久内存编程:应用程序可以直接在NVM上操作数据结构,无需显式的序列化

Linux内核从5.1版本开始支持devdaxfsdax两种NVM访问模式,为未来的内存密集型应用提供了新的可能性。

然而,即使有了这些新技术,理解缺页中断的原理仍然至关重要。因为只要存在虚拟内存抽象,只要存在比CPU缓存更慢的存储层次,就需要某种形式的页面管理和缺页处理。优化内存访问模式、控制工作集大小、避免颠簸——这些基本原则不会因为硬件的进步而过时。


参考资料

  1. Wikipedia - Page fault: https://en.wikipedia.org/wiki/Page_fault
  2. Arseny Kapoulkine - A queue of page faults: https://zeux.io/2014/12/21/page-fault-queue/
  3. Erik Rigtorp - Latency Implications of Virtual Memory: https://rigtorp.se/virtual-memory/
  4. System Overflow - Demand Paging and Page Fault Latency Impacts: https://www.systemoverflow.com/learn/os-systems-fundamentals/memory-management/demand-paging-and-page-fault-latency-impacts
  5. IEEE Milestones - Atlas Computer and the Invention of Virtual Memory, 1957-1962: https://ethw.org/Milestones:Atlas_Computer_and_the_Invention_of_Virtual_Memory,_1957-1962
  6. Peter J. Denning - The Working Set Model for Program Behavior (CACM, 1968): https://denninginstitute.com/pjd/PUBS/WSModel_1968.pdf
  7. Peter J. Denning - Thrashing: Its causes and prevention (AFIPS, 1968): https://dl.acm.org/doi/10.1145/1476589.1476705
  8. Stanford University - Thrashing and Working Sets: https://web.stanford.edu/~ouster/cgi-bin/cs140-winter12/lecture.php?topic=thrashing
  9. Linux Kernel Documentation - Page Tables: https://docs.kernel.org/mm/page_tables.html
  10. Linux Kernel Documentation - zswap: https://docs.kernel.org/admin-guide/mm/zswap.html
  11. Red Hat Documentation - Using mlock() system calls on RHEL for Real Time: https://docs.redhat.com/en/documentation/red_hat_enterprise_linux_for_real_time/8/html/optimizing_rhel_8_for_real_time_for_low_latency_operation/assembly_using-mlock-system-calls-on-rhel-for-real-time_optimizing-rhel8-for-real-time-for-low-latency-operation
  12. zolutal - Understanding x86_64 Paging: https://blog.zolutal.io/understanding-paging/
  13. GeeksforGeeks - Translation Lookaside Buffer (TLB) in Paging: https://www.geeksforgeeks.org/operating-systems/translation-lookaside-buffer-tlb-in-paging/
  14. Intel VTune Profiler Cookbook - Page Faults: https://www.intel.com/content/www/us/en/docs/vtune-profiler/cookbook/2023-0/page-faults.html