引言:当内存访问踩入"空洞"
在《Linux内核内存管理深度实战》中,我们从伙伴系统(Buddy System)和 SLUB 分配器的角度拆解了物理页的分配与回收。承接上一篇内存管理的工程细节,Major Page Fault(主缺页)——那个让进程阻塞在磁盘 I/O 上的"隐形杀手"——在生产环境中往往是尾延迟毛刺(Tail Latency Spike)的幕后推手。当一个 NUMA 拓扑中 4KB 页面的远程访问延迟已经无法接受时,本文将从 TLB 未命中的硬件信号岁开始,穿越多级页表遍历(Page Table Walk),俯瞰内核的 Major Fault 处理链路、巨页映射机制、NUMA 自动均衡策略(AutoNUMA)、以及 mmap() 和 madvise() 的调优手段。
阅读本文后,你将能够:
- 拆解一次 Major Page Fault 在内核中经历的全部函数调用链(
handle_mm_fault → do_swap_page → do_fault) - 理解 TLB、PCID、INVPCID 与 ASID 在地址翻译中的角色
- 用
perf和bpftrace定位 Major Fault 热点并量化其对延迟的贡献 - 掌握 hugetlbfs、THP(Transparent Huge Page)与 libhugetlbfs 的工程取舍
- 通过 NUMA 绑定、mbind()、AutoNUMA 调优消除跨节点内存访问
- 将冷启动 Major Fault 降低一个数量级的 3 种实战模式(预读、预映射、预取)
1. 从虚拟地址到物理页:TLB、页表与 Fault 分类
x86-64 使用四级页表(PML4 → PDPT → PD → PT)将 48 位(或 57 位五级页表)虚拟地址翻译为物理地址。CPU 的 MMU 在每次内存访问时执行这一翻译流程:
- 检查 TLB(Translation Lookaside Buffer)中是否有缓存项
- 若 TLB 未命中(TLB Miss),触发硬件 Page Table Walker 遍历页表
- 若最终页表项(PTE)不存在(!PTE_PRESENT),触发 Page Fault (#PF)异常
Linux 将 Page Fault 分为三类:
| 类型 | 触发条件 | 阻塞? | 典型来源 |
|---|---|---|---|
| Minor Fault | 页面在物理内存中但未建立映射(如 page cache 命中) | 否 | 第一次读取 mmap 文件、CoW fork |
| Major Fault | 页面不在内存,需从磁盘(swap 或文件)加载 | 是(毫秒~数十毫秒) | 首次读取大文件、swap 回收后重新访问 |
| Invalid Fault | 访问未映射地址或权限违规 | 进程退出/SIGSEGV | 野指针、栈溢出、mprotect 冲突 |
在 perf stat -e dtlb_load_misses.stlb_hit,dtlb_load_misses.miss_causes_a_walk 中你可以同时观察 L1 D-TLB 未命中(stlb_hit,触发 STLB/Second-Level TLB 查找)和最终需要 Page Table Walk 的未命中(miss_causes_a_walk)。当一个 2MB THP 因 madvise 或碎片化问题被拆分时,thp_fault_alloc 计数器会飙升。
2. Major Fault 核心路径:从异常入口到 I/O 完成
2.1 硬件到内核的入口
CPU 触发 #PF 后,x86 硬件将错误地址存储到 CR2 寄存器,然后跳转到 entry_INT64_32 中的 page_fault 入口,最终在 arch/x86/mm/fault.c:do_page_fault() 中调用通用 MM 层接口:
__do_page_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address)
→ handle_mm_fault(vma, address, flags) // mm/memory.c
→ handle_pte_fault() // PTE 级别处理
→ do_swap_page() // 页面在 swap 中
→ do_read_fault() // 文件映射缺页
→ do_cow_fault() // 写时复制
→ do_shared_fault() // 共享映射缺页
→ do_wp_page() // 写保护 → 复制新页
如果 PTE 不存在(!pte_present(entry)),handle_pte_fault() 会调用 do_swap_page()(匿名页换出后再换入)或 do_fault() 系列(文件映射缺页)。
2.2 文件映射缺页:do_read_fault
当应用通过 mmap() 映射了一个文件后,首次访问某个页面时,vma->vm_ops->fault() 被调用。对 ext4/xfs 等常规文件系统,最终走向 filemap_fault():
do_fault()
→ do_read_fault()
→ __do_fault()
→ vma->vm_ops->fault(vma, vmf) // 文件系统回调
→ ext4_filemap_fault()
→ filemap_fault()
→ pagecache_get_page() // page cache 查找
→ if (!page):
page_cache_alloc() // 分配新 cache 页
readpage_one() // 提交 BH 读请求(实际磁盘 I/O)
wait_on_page_locked() // 阻塞等待 I/O 完成
关键延迟来源:wait_on_page_locked() 使进程进入 TASK_UNINTERRUPTIBLE 状态,等待块设备层(Block Layer)返回。一块 NVMe SSD 的 4KB 随机读约 10-100μs;一块 SATA SSD 约 1-2ms;一块企业级 HDD 可达 10ms+。
2.3 Swap 换入:do_swap_page
上一次我们讲过 kswapd 的回收流程:当匿名页被换出到 zram/swap 分区时,PTE 变为 swp_entry_t 指针。重新访问时:
do_swap_page()
→ swp_entry_to_pte(&entry)
→ lookup_swap_cache() // 优先在 swap cache 中查找(避免磁盘 I/O)
→ if miss:
swapin_readahead() // 异步预读邻近页面
swap_readpage() // 读取磁盘 swap/zram
wait_on_page_locked() // 阻塞等待
→ do_page_add_anon_rmap() // 删除 rmap 映射
→ set_pte_at() // 更新 PTE → 物理页
如果启用了 CONFIG_ZRAM,swap 换入走的是 zram 的解压路径(如 zstd/lzo-rle),延迟可下降一个数量级。swapin_readahead() 的窗口大小从 Linux 5.x 的 16 页扩展到 32 页(可添加 /proc/sys/vm/page-cluster 调优,通常设为 0-5 表示 2^n 窗口)。
3. 从 TLB 到 PCID:地址翻译的硬件加速与隔离
3.1 TLB 结构与刷新机制
x86 的 TLB 是 set-associative 缓存。现代 Intel CPU 通常有:
- L1 D-TLB:64 项 4-KB / 32 项 2MB/4MB (STLB)
- L2 STLB:1536 项 4-KB + 巨页混合
- ITLB:128 项 4-KB / 8 项 2MB/1GB (全关联)
当 PTE 更新后(建立/删除映射),必须使相关 TLB 项失效。Linux 通过 flush_tlb_mm_range() 触发 INVLPG(单页)或 CR3 write(全部)指令实现。频繁的 TLB shootdown(跨核 IPI)是上下文切换的主要开销之一。
3.2 PCID / ASID:避免每次 CR3 切换都刷新 TLB
PCID(Process-Context Identifier) 是实现的关键。CR3 的低 12 位存储 PCID(最多 4096 个,受 VMX 限)以避免每在相应 CR3 write 时刷新全部 TLB。Linux 的 PCID 策略分两级:
# 查看 PCID 是否启用
$ dmesg | grep -i pcid
[ 0.159952] x86: Booting SMP configuration:
[ 0.160016] smpboot: CPU0
# 内核源码:arch/x86/include/asm/tlbflush.h
# CONFIG_HAVE_PCID_CPUFLUSH=y
在 64 位 Linux 中,PCID 模式分两种:
- User PCID(bit 63=0):每个 mm 一个 PCID,CR3 write 时只刷新当前 PCID 的 non-global 项
- Global Pages(PGL 全局位):标记内核页面在所有上下文中共享,刷新时不失效
KPTI(Kernel Page Table Isolation,补丁自 Meltdown 漏洞)在用户态和内核态使用不同的页表,每次系统调用/中断触发 CR3 write。若无 PCID,KPTI 会导致每次切换都全部清 TLB,性能损失可达 30%。启用 PCID 后此项开销被基本消除。
3.3 INVPCID:更细粒度的 TLB 刷新
INVPCID(Invalidates TLBs and Paging-Structure Caches)指令支持四种模式:
| INVPCID Type | 效果 |
|---|---|
| 0 | 对应线性地址 + PCID 的指定 PTE(同时移除所有等级缓存) |
| 1 | 全部 mapping for given PCID(除 global pages) |
| 2 | ALL contexts, all PCIDs(完整刷新) |
| 3 | 除 PCID 指定的 mapping 之外(除 global pages) |
内核的 tlb_flush_all() 和 tlb_flush_mm_range() 在检测到 CPU 支持 INVPCID 后会优先走它,避免不必要的 CR3 全部刷新。这对使用大地址空间(100GB+)的数据库应用尤其关键——全页表 TLB 条数可能达到数万项。
4. 巨页(Huge Page / THP):减少 TLB 压力与 Fault 次数
4.1 三次翻译,三重优势
相同工作集使用 4KB 页 vs 2MB THP vs 1GB HugePage 的 TLB 覆盖率差异巨大。以一个 1GB 内存占用的数据库缓冲池为例:
| 页面大小 | 页面数量 | 所需 TLB 项(L2 STLB) | 一次 TLB 未命中覆盖的缺页耗时 |
|---|---|---|---|
| 4 KB | 262,144 | 262,144 项 >> L2 STLB(1536 项) | 单次 Fault ~0.1ms,大量重复触发 |
| 2 MB (THP) | 512 | 512 项 < L2 STLB | 单次 Fault ~0.1ms,但次数极少 |
| 1 GB (HugePage) | 1 | 1 项 | 超大页面,首次 Fault 耗时确定 |
结论是:使用 THP/HugePage 时,TLB 未命中率下降 100-500 倍,Major Fault 的总次数也线性减少。
4.2 THP(Transparent Huge Page):内核的自动巨页拆分
THP 通过 khugepaged 内核线程将碎片化的 4KB 页面合并为 2MB 页面:
/sys/kernel/mm/transparent_hugepage/enabled = "madvise" # 推荐:显式启用
/sys/kernel/mm/transparent_hugepage/defrag = "defer+madvice" # 减少直接 compaction 开销
/sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs = 10000
/sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs = 60000
问题在于:
- THP 的
defrag行为会触发大量页面迁移(split_huge_page_to_list) - 在 NUMA 系统中,TEP 可能试图迁移远程节点上的页面到本地节点,导致性能反而下降
- 内存紧张的容器中,THP 的 2MB 分配更容易触发 OOM(4KB 分页可通过回收部分页面避免)
许多高性能数据库(Redis、PostgreSQL)建议关闭 THP或仅通过 madvise(MADV_HUGEPAGE) 显式启用。
4.3 libhugetlbfs 与显式 HugePage
对延迟敏感的系统(高频交易、电信信令面),预分配 1GB HugePage 是标准做法:
# 在 NUMA 节点 0 预分配 8 个 1GB HugePage
echo 8 > /sys/devices/system/node/node0/hugepages/hugepages-1048576kB/nr_hugepages
# 或通过启动参数(早期初始化,避免碎片化)
hugepagesz=1G hugepages=8 default_hugepagesz=1G
# 预留文件挂载
mount -t hugetlbfs nodev /dev/hugepages1G
# C 代码:显式 mmap 巨页
int fd = open("/dev/hugepages1G/data", O_CREAT|O_RDWR, 0666);
void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE,
MAP_SHARED|MAP_HUGETLB|((30UL << MAP_HUGE_SHIFT)), // 1GB = 2^30
fd, 0);
显式 HugePage 的巨页不会被 swap 掉(不可回收),这是吞吐与延迟的取舍。
5. NUMA(Non-Uniform Memory Access):远程访问下的延迟黑洞
5.1 NUMA 拓扑与本地化率
多 socket 系统中,每个 socket 有本地内存节点(Node)和远程内存节点。Intel Xeon 的跨节点访问延迟通常是本地节点的 1.5-2 倍;AMD EPYC(Zen 4)由于 chiplet + IOD 架构,这一比例可达 2-3 倍。
# 查看 NUMA 拓扑
$ numactl --hardware
available: 2 nodes (0-1)
node 0 size: 64500 MB
node 0 free: 43000 MB
node 1 size: 64500 MB
node 1 free: 52000 MB
node distances:
node 0 1
0: 10 16 # 对角线=本地(10),非对角线=远程(16)
1: 16 10
$ lscpu | grep NUMA
NUMA node(s): 2
NUMA node0 CPU(s): 0-31
NUMA node1 CPU(s): 32-63
First-Touch 策略:Linux 默认的 NUMA 分配策略是 MPOL_DEFAULT(localalloc)。页面在首次被写入的 CPU 的本地节点分配。这意味着:在 Node 0 上启动的线程对某段内存的首次写入,决定了这片内存的物理位置。
如果后续访问来自 Node 1,就会产生远程访问——Major Fault 在远程节点上导致的 TLB shootdown 和 page table walker 延迟更加严重。
5.2 AutoNUMA:内核的运行时均衡机制
Linux 3.8 引入的 AutoNUMA(需开启 CONFIG_AUTONUMA)会在运行时扫描某段内存的访问热度,并将页面迁移到最活跃的 NUMA 节点:
/proc/sys/kernel/numa_balancing = 1 # 全局开关
/proc/sys/kernel/numa_balancing_scan_delay_ms = 1000 # 启动后多久开始扫描
/proc/sys/kernel/numa_balancing_scan_period_min_ms = 1000
/proc/sched_autogroup_enabled # 自动分组协助
AutoNUMA 的工作流程:
knuma_balanced内核线程定期标记访问器(通过clear page table accessed bits)- 再次访问触发
pte_young()后被置位,扫描算法收集热度数据 - 热度不平衡时调用
migrate_misplaced_pages()将页面从冷节点迁移到热节点
但 AutoNUMA 的扫描开销本身不可忽视——在高负载 OLTP 系统中,每 1000ms 扫描 256MB 范围的 PTE 位,可能会短暂抢占应用 CPU。PostgreSQL 社区建议在 OLTP 负载中关闭 AutoNUMA,改用显式 numa 绑定。
5.3 显式 NUMA 绑定与 mbind
对于已知流量模式的系统,显式绑定比自动均衡更稳定:
// CPU + 内存双绑定
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);
unsigned long nodemask = 1UL << 0; // Node 0
mbind(ptr, size, MPOL_BIND, &tempmask, sizeof(nodemask)*8, MF_MOFUE|MF_MOVE);
MPOL_BIND:严格绑定到指定节点,若节点内存不足则触发 OOM;MPOL_PREFERRED(软策略):优先使用指定节点,若不足回退到其他节点。Redis 在多 NUMA 节点服务器的最佳实践是:将线程绑定到某 NUMA 节点的 CPU 并同时将内存分配绑定到同一节点,避免跨节点 Flash/持久化 I/O 路径冲突。
6. 生产环境调优:从指标到方案
6.1 指标采集:定位 Major Fault 来源
# 1. 按进程统计 Fault 次数(minor + major)
$ pidstat -r -p ALL 1
Average: UID PID minflt/s majflt/s VSZ RSS
Average: 1000 12345 2541.00 45.00 8.2g 6.1g
# 2. 用 perf 采样缺页的调用栈
$ perf record -e page-faults -ag -- sleep 10
$ perf report --sort=comm,dso
# 3. bpftrace 实时观测缺页路径
$ bpftrace -e '
kprobe:do_page_fault {
@comm = comm;
@start = nsecs;
}
kprobe:do_page_fault /@start/ {
@lat_us = hist((nsecs - @start) / 1000);
delete(@start);
}'
# 4. 查看 THP 拆分与 khugepaged 活动
$ grep -E 'thp|khugepaged' /proc/vmstat | head -20
thp_fault_alloc 2360
thp_fault_fallback 1483 # 巨页分配失败次数高 → 内存碎片化
thp_split_page 971
thp_split_page_failed 3
6.2 四种模式的工程方案
方案 A:预读 + 预加载 + mincore——适合启动密集型、后续恒定型负载(搜索引擎、OLAP 查询)。通过 madvise(MADV_WILLNEED) 或 readahead() 系统调用在启动时将文件 page cache 加载到内存,后续访问几乎不会触发 Major Fault。在启动阶段注入:
void preload_file(const char *path) {
int fd = open(path, O_RDONLY);
struct stat st;
fstat(fd, &st);
void *ptr = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED|MAP_POPULATE, fd, 0);
madvise(ptr, st.st_size, MADV_WILLNEED | MADV_SEQUENTIAL);
munmap(ptr, st.st_size);
close(fd);
}
MAP_POPULATE 标志在 mmap 时即触发全部缺页(相当于预读),MADV_SEQUENTIAL 提示内核增加预读窗口。
方案 B:NUMA 本地化 + mbind 硬绑定——适合多核 NUMA 同构系统。将工作线程按 NUMA 节点分区,所有 I/O 缓冲区和索引结构绑定到本地节点。PostgreSQL 的 numa.opennuma 配置项或自带 pg_numa 扩展库是用于此目的。
方案 C:延迟敏感 + 1GB HugePage——电信用户面(UPF)、高频交易。在启动参数预留 1GB HugePage,通过 libhugetlbfs 显式分配延迟敏感的数据结构,将此段内存锁定(mlock)避免被 swap。
方案 D:内存压力大的容器 → 关闭 THP + 使用 4KB 页——在 K8s 有限的 resources.limits.memory 下,关闭 transparent_hugepage/defrag,使用 madvise(MADV_NOHUGEPAGE) 对关键路径禁用。这是因为 THP 的 2KB compaction 和 khugepaged 扫描可能触发额外的页面移动和延迟尖刺。
6.3 eBPF 可观测:实时追踪 Major Fault
在我们的《eBPF深度实战》中详细描述了 eBPF 挂钩原理。结合 BCC 工具集,可直接观测 Major Fault:
# 实时打印每个 Major Fault 的进程、地址、延迟
$ bpftrace -e '
kprobe:do_swap_page {
@comm = comm;
@ts[tid] = nsecs;
}
kretprobe:do_swap_page /@ts[tid]/ {
$dur = (nsecs - @ts[tid]) / 1000;
if ($dur > 1000) {
printf("SWAPIN SLOW %s pid=%d %d us\n", @comm, pid, $dur);
}
delete(@ts[tid]);
}'
同样挂钩 do_page_mkwrite、do_anonymous_page 可以监测 CoW(写时复制)活动的频率。在容器化 OLTP 数据库中,CoW Fault 过高通常暗示过度的 fork + exec 调用——可能来自连接池的实现而非进程本身的 CoW。
7. 完整端到端测试:模拟 Major Fault 并观测
下面是一个 C 程序,通过 mmap 1GB 匿名页面并逐 4KB 写入触发连续 Major Fault(仅一次写入触发一次 CoW + 缺页,匿名页 CoW 实际上是 do_anonymous_page,不是 Major Fault;真正的 Major Fault 需要从 swap/file 加载)。真正的 Major Fault 压力测试需要设置 madvise 然后强制回收:
# 下载并编译
$ cat > major_fault_test.c <<'EOF'
#define _GNU_SOURCE
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <fcntl.h>
#define SIZE_GB (1UL * 1024 * 1024 * 1024)
#define PAGE_SIZE 4096
int main() {
// 1. 用 MAP_POPULATE + MAP_LOCKED 预加载,消除 Major Fault
void *p = mmap(NULL, SIZE_GB, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE|MAP_LOCKED,
-1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
// 2. 用 MADV_DONTNEED 丢弃页面(模拟冷启动缺页)
madvise(p, SIZE_GB, MADV_DONTNEED);
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
// 3. 逐页触发缺页
for (size_t off = 0; off < SIZE_GB; off += PAGE_SIZE) {
memset(p + off, 0x42, PAGE_SIZE);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
double dur = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec)*1e-9;
printf("1GB zero-fill: %.2f sec, throughput: %.2f GB/s\n", dur, 1.0/dur);
munmap(p, SIZE_GB);
return 0;
}
EOF
$ gcc -O2 -o major_fault_test major_fault_test.c
对比测试:在 4-socket Intel Xeon(每 socket 本地带宽约 100 GB/s,跨 socket 约 40 GB/s)上运行,不绑 NUMA 节点时吞吐约 12 GB/s,绑定到某 NUMA 节点后可达 30 GB/s——这主要不是吞吐本身的提升,而是缺页延迟下降带来的 Page Table Walker 争用度降低。
8. 结论与延伸阅读
Major Page Fault 是连接虚拟内存子系统、文件系统、块设备层、NUMA 拓扑的十字路口。它的处理链路从硬件的 TLB、Page Table Walker,到内核的 handle_mm_fault → do_fault,再到 I/O 层的页面读回,每一跳都有可能引入数量级级别的延迟差异。
在工程实践中,我们依据场景选择侧重点:
- 吞吐优先:增大 TLB 覆盖率(THP/1GB HugePage) + 预读预加载
- 延迟确定性:禁用 THP + NUMA 硬绑定 + mlock 巨页
- 容器自适应:关闭 THP + 使用 4KB 页 + 内存限制 + AutoNUMA
相关的延伸阅读:
- Linux内核内存管理深度实战——Buddy/SLUB/MMU
- eBPF Linux内核可观测性深度实战——用biolatency、biosnoop工具观测缺页 I/O
- Linux内核RUST编程实战——Rust 中 unsafe mmap 绑定的工程实践
下一篇我们将进入Linux 内核块设备层(Block Layer):multi-queue blk-mq、io_uring 的 fixed buffer 与 SQE/CQE 链路如何与 Major Fault 的后半段 I/O 路径在 truly 零拷贝层面上协同工作。

发表评论 取消回复