引言:当内存访问踩入"空洞"

在《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 在每次内存访问时执行这一翻译流程:

  1. 检查 TLB(Translation Lookaside Buffer)中是否有缓存项
  2. 若 TLB 未命中(TLB Miss),触发硬件 Page Table Walker 遍历页表
  3. 若最终页表项(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)
2ALL 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 KB262,144262,144 项 >> L2 STLB(1536 项)单次 Fault ~0.1ms,大量重复触发
2 MB (THP)512512 项 < L2 STLB单次 Fault ~0.1ms,但次数极少
1 GB (HugePage)11 项超大页面,首次 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 的工作流程:

  1. knuma_balanced 内核线程定期标记访问器(通过clear page table accessed bits)
  2. 再次访问触发 pte_young() 后被置位,扫描算法收集热度数据
  3. 热度不平衡时调用 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 内核块设备层(Block Layer):multi-queue blk-mq、io_uring 的 fixed buffer 与 SQE/CQE 链路如何与 Major Fault 的后半段 I/O 路径在 truly 零拷贝层面上协同工作。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论