Linux 内核缺页中断路径深度工程实战:从 CPU 异常到页表同步的完整链路

在整个 Linux 内核执行生命周期中,缺页中断(Page Fault)处理程序是调用频率最高、维护成本最大的异常处理器。每一次进程创建、内存映射、写时复制(CoW)fork、userfaultfd 事件、NUMA 大页迁移以及透明大页合并,都必须经过这条路径。在生产环境中,缺页中断的效率直接决定了内存密集型应用的性能上限 —— 多一次缓存未命中或不必要的自旋锁,在 TensorFlow 大模型加载或 ClickHouse 分析查询中都会被放大成秒级延迟。本文将从工程实践的角度,深度分析从 CPU 硬件异常触发到内核处理程序的完整路径,附大量带注释的代码分析与生产调优建议。

一、硬件基础:CPU 如何触发缺页中断

CPU 访问虚拟地址时,MMU 通过页表进行地址转换。若目标页表项(PTE)标记为"不存在"(x86 PTE_P=0),或访问权限不符合,CPU 会自动将出错地址保存到专用寄存器并跳转到异常处理器。

架构相关寄存器

c
// x86:CR2 寄存器保存触发缺页的虚拟地址
static inline unsigned long read_cr2(void)
{
    unsigned long val;
    asm volatile("mov %%cr2,%0\n" : "=r" (val));
    return val;
}

// arm64:FAR_EL1(Fault Address Register)保存出错虚拟地址
static inline u64 read_sysreg_far(void)
{
    u64 val;
    asm volatile("mrs %0, far_el1" : "=r" (val));
    return val;
}

Error Code 位图:区分关键

x86 CPU 向栈上压入 32 位错误码,其位图直接指示缺页的性质:

Bit Name 含义
0 P 0=页面不存在,1=权限错误
1 W/R 0=读,1=写
2 U/S 0=内核态,1=用户态
3 RSVD PTE 保留位被置位
4 I/D 取指异常
5 PK Protection Key 违反
6 SS 影子栈访问错误

这些位决定了后续处理程序的核心分支和权限策略。例如 U/S + W/R + P 的掩码 0x7 —— 用户态写不存在的页面 —— 是按需分页(demand paging)的最常见场景;内核态的 0x18 可能意味着 KPTI 切换后访问了内核代码页。

二、x86 do_page_fault:汇编入口与快速路径优化

x86 缺页中断处理程序的调用链为:


GPA → IDT entry 0x0E → page_fault (entry_64.S) → do_page_fault

汇编头部负责切换上下文栈、保存通用寄存器,以及最关键的 —— 尽早加载 CR2 捕获出错地址:

nasm
// arch/x86/entry/entry_64.S
ENTRY(page_fault)
    // 1. 若来自用户态则切换到内核栈
    swapgs
    // 2. 在内核代码扰乱指令流水线之前就读取 CR2
    movq    %cr2, %rdi    # 第一个参数 = 缺页地址

    // 3. 保存上下文后调用 C 函数
    call    do_page_fault
END(page_fault)

为何 CR2 必须第一时间读取?因为若在读 CR2 之前又触发了一个嵌套缺页中断,CR2 会被覆盖。因此内核入口必须将读 CR2 作为绝对最高优先级操作。

C 入口:权限前置检查

c
// arch/x86/mm/fault.c
static noinline void
do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
    struct vm_area_struct *vma;
    struct mm_struct *mm = current->mm;
    unsigned long address;
    vm_fault_t fault;

    // 防止嵌套缺页导致 CR2 被覆盖的临界区
    address = read_cr2();

    // === 快速路径决策 ===
    // 1. 优先尝试 fixup_exception(内核态访问用户空间异常)
    if (unlikely(fixup_exception(regs, address)))
        return;

    // 2. KPROBE/KRETPROBE 黑名单检查
    if (unlikely(kprobe_page_fault(regs, X86_TRAP_PF)))
        return;

    // 3. 抢占关闭需延迟到此处 —— 出错地址必须是可读的
    //    即使在 CONFIG_PREEMPT=y 的情况下

    // === 进入临界区 ===
    if (unlikely(!mmap_read_trylock(mm)))        // 读锁保护 mm(非阻塞)
        return handle_bad_page_fault(regs, ...);

    // === 第一阶段:定位 VMA ===
    vma = find_vma(mm, address);
    if (unlikely(!vma))                           // 地址低于 mm->mmap_base
        goto bad_area;
    if (likely(vma->vm_start <= address))         // 快速路径:直接找到
        goto good_area;
    // 罕见路径:栈未扩展或搜索命中 vm_flags 间隙
    if (unlikely(!(vma->vm_flags & VM_GROWSDOWN)))
        goto bad_area;
    // 栈扩展:允许增加一页(受 stack rlimit 限制)
    if (unlikely(expand_stack(vma, address)))
        goto bad_area;

good_area:
    // === 第二阶段:权限验证 ===
    if (unlikely(error_code & PF_WRITE)) {
        if (!(vma->vm_flags & VM_WRITE))          // 写只读 VMA
            goto bad_area;
    } else if (unlikely(!(vma->vm_flags & VM_READ))) // 读无读权限 VMA
        goto bad_area;

    // === 第三阶段:缺页处理核心 ===
    fault = handle_mm_fault(vma, address, flags, regs);
    // 处理返回值:VM_FAULT_ERROR、VM_FAULT_NOPAGE、VM_FAULT_RETRY 等

    if (fault & VM_FAULT_ERROR)
        mm_fault_error(regs, error_code, address, fault);

    // 重试逻辑:VM_FAULT_RETRY 时需要回退重试次数
    if (fault & VM_FAULT_RETRY) {
        if (flags & FAULT_FLAG_ALLOW_RETRY) {
            ...
        }
    }
}

这里的工程智慧在于三层快速判断树:

1. fixup_exception 优先:内核代码无意中读写用户空间地址(如 copy_from_user),此时用户进程没问题 —— 是内核自身的 bug。fixup_exception 跳转到 __ex_table 保存的修复寄存器,是一种内核"自愈"机制

2. find_vma 再定位一次:定位 VMA 后再次尝试 find_vma 以避免竞态,且栈的 VM_GROWSDOWN 不需要预先知晓具体边界

3. handle_mm_fault 主段:真正的页表操作

三、arm64 do_page_fault:架构差异

arm64 的缺页中断入口与 x86 有显著差异:

c
// arch/arm64/mm/fault.c
static void __do_kernel_fault(unsigned long addr, unsigned long esr,
                              struct pt_regs *regs)
{
    // ESR 提供 ESR_EL1.EC(异常类别):
    //   0x20: 低异常级指令中止
    //   0x21: 同级指令中止
    //   0x24: 低异常级数据中止(即用户态缺页)
    //   0x25: 同级数据中止(即内核态缺页)
    if (fixup_exception(regs))
        return;

    // 内核访问出错 → 搜索回调表
    if (!is_ttbr0_addr(addr) && !search_exception_tables(regs->pc))
        die_kernel_fault("access", addr, esr, regs);
}

static void do_page_fault(unsigned long addr, unsigned long esr,
                          struct pt_regs *regs)
{
    // 1. 在开中断前读取缺页地址 —— 防止嵌套异常覆盖 FAR_EL1
    task = current;
    mm = task->mm;

    // 2. ESR 解码:4-bit DFSC 确定缺页性质
    if (esr & ESR_ELx_ISV) {    // Syndrome 有效
        // DFSC[3:0] 编码:
        //   0x00: 地址大小故障(level 0-3)
        //   0x04: 翻译故障(level 1-3)
        //   0x08: 访问标志故障
        //   0x0C: 权限故障
        //   0x18: 同步奇偶校验故障(level 0-3)
        ...
    }

    // 3. 内核地址出错:立即搜索异常表
    if (is_el1_fault(esr) && is_el1_instruction_abort(esr))
        return __do_kernel_fault(addr, esr, regs);

    // 4. VM_LOCATE → VM_CHECK → handle_mm_fault 链与 x86 一致
    vma = find_vma(mm, addr);
    ...
}

arm64 与 x86 的核心差异在于 ESR 解码的复杂度。arm64 通过 ESR_ELx 提供了更丰富的编码:

  • EC(Exception Class) 判定是指令中止还是数据中止
  • DFSC(Data Fault Status Code) 直接指示是翻译故障、访问标志故障还是权限故障
  • WnR(Write not Read) 等价于 x86 的 PF_WRITE 位

这意味着 arm64 通过一次 ESR 读取即可区分不同缺页原因,而 x86 需要同步读取 CR2 地址和 PTE 内容。

四、handle_mm_fault:核心分发与节点分配策略

handle_mm_fault 是架构无关的缺页中断处理核心:

c
// mm/memory.c
vm_fault_t handle_mm_fault(struct vm_area_struct *vma,
                           unsigned long address, unsigned long flags,
                           struct pt_regs *regs)
{
    vm_fault_t ret;

    if (unlikely(!vma_is_anonymous(vma))) {
        // 文件映射:尝试预读和按需读取路径
        ret = do_fault(vma, address, flags, regs);
    } else if (vma->vm_flags & VM_WRITE) {
        // 匿名可写映射:会尝试共享零页,再分配 anon 页
        ret = do_anonymous_page(vma, address, flags, regs);
    }

    // 架构回调:允许 x86/arm64 各自在缺页中断前注入处理
    if (unlikely(__handle_mm_fault_prep(vma, address, flags)))
        return VM_FAULT_RETRY;

    // 核心:遍历页表层级并填充叶子 PTE
    ret = __handle_mm_fault(vma, address, flags, regs);
    return ret;
}

4.1 do_fault:文件映射的处理

c
static vm_fault_t do_fault(struct vm_area_struct *vma,
                           unsigned long address, unsigned long flags,
                           struct pt_regs *regs)
{
    if (flags & FAULT_FLAG_WRITE) {
        // 1. 写异常:文件必须是 VM_SHARED 或 私有 CoW
        if (vma->vm_flags & VM_SHARED)
            return do_shared_fault(vma, address, flags, regs);   // 直接写回缓存页
        else
            return do_cow_fault(vma, address, flags, regs);     // 分配 CoW 页
    } else {
        // 2. 读异常:预读或按需分页
        return do_read_fault(vma, address, flags, regs);         // 先从 page cache 读取
    }
}

do_read_fault 路径是文件映射缺页读取优化的核心:

c
static vm_fault_t do_read_fault(struct vm_area_struct *vma,
                                unsigned long address, unsigned long flags,
                                struct pt_regs *regs)
{
    // 1. 检查页缓存(所有 pagecache 页均可通过 get_user_pages 命中)
    page = find_get_page(...);

    if (page) {
        // 命中页缓存:零 TLB miss,设置 PTE
        return VM_FAULT_MINOR;  // Minor fault,无 swapin
    }

    // 2. 页缓存未命中:执行预读
    // fault-around:故障地址为中心 ×4 页的批量读取
    fault_around_bytes = vma->vm_file->f_ra.ra_pages;
    if (fault_around_bytes < PAGE xss=removed>

fault-around 的工程意义:默认 fault_around_bytes 约为 64KB(16 页),即每次 4KB 缺页中断会触发一次 64KB 的顺序预读。对于顺序扫描的数据库(ClickHouse Parquet 扫描、InnoDB 全表扫描),这可以将 I/O 次数降低 16 倍。但还有 15/64 的"脏读"开销。因此生产调优中常对这类数据库设置 vm.fault_around_bytes=16(仅 4KB)。

4.2 do_anonymous_page:匿名页与零页

匿名页来源于 malloc()(mmap 匿名映射)、brk 堆扩展、栈扩展等:

c
static vm_fault_t do_anonymous_page(struct vm_area_struct *vma,
                                    unsigned long address, unsigned long flags,
                                    struct pt_regs *regs)
{
    // 快速路径 1:读异常 → 立即映射到全局零页
    if (!(flags & FAULT_FLAG_WRITE) && !vma_is_anonymous_dma(vma)) {
        // 将零页的只读映射写入 PTE
        // zero page 是静态分配的全 0 字节页
        entry = pte_mkspecial(pfn_to_pte(zero_pfn, vma->vm_page_prot));
        return VM_FAULT_DONE;  // 最高性能级别:无分配
    }

    // 快速路径 2:写异常 → 分配匿名页
    page = alloc_pages_vma(gfp_mask, 0, vma, address, false);
    if (!unlikely(page)) {
        return VM_FAULT_OOM;
    }

    // 清除页内容(安全:防止信息泄露)
    clear_highpage(page);

    // 插入页到 VMA 反向映射(rmap)
    __page_set_anon_rmap(page, vma, address, false);

    // 设置 PTE 为 present + writable + accessed
    entry = mk_pte(page, vma->vm_page_prot);
    set_pte_at(mm, address, ptep, entry);

    return VM_FAULT_WRITE;
}

零页为何特殊? Linux 内核在物理内存中静态保留一个 4KB 的全 0 字节页(零页)。进程初始化一个匿名区域(如 BSS 段)时,若读异常,并不分配新页 —— 而是直接将 PTE 映射到这个共享零页并标记为只读。仅在第一次写访问时,才触发真正分配新页的 CoW 流程。此设计实现了:

  • 减少内存分配压力:零值 BSS 段不消耗实际物理页
  • 减少 TLB 未命中:零页在多次进程切换中保持在 TLB 中
  • 减少 RSS:进程 RSS 只计算实际物理页,不会将"零值内存"计入

五、\_\_handle\_mm\_fault:深入页表遍历

真正的页表操作在 __handle_mm_fault 中完成,它使用递归宏遍历 4-5 级页表:

c
// mm/memory.c
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
                                    unsigned long address, unsigned long flags,
                                    struct pt_regs *regs)
{
    pgd_t *pgd;
    p4d_t *p4d;
    vm_fault_t ret;

    // Level 1: PGD (Page Global Directory)
    pgd = pgd_offset(mm, address);
    // x86-5level:pgd 可以是大页映射
    if (unlikely(pgd_none(*pgd))) {
        // PGD 为空,分配 P4D
        p4d = p4d_alloc(mm, pgd, address);
        if (unlikely(!p4d))
            return VM_FAULT_OOM;
    } else if (unlikely(pgd_trans_huge(*pgd))) {
        // PGD 级 1GB 大页(x86 PGD_PAGE_SIZE >= 1GB)
        return pgd_huge_gfn_update(vma, address, pgd, flags);
    } else {
        p4d = p4d_offset(pgd, address);
    }

    // Level 2: P4D (4th level Page Directory)
    if (p4d_none(*p4d)) {
        // 分配 PUD
        pud = pud_alloc(mm, p4d, address);
        ...
    } else if (unlikely(pud_trans_huge(*pud))) {
        // PUD 级 2MB 或 1GB 大页
        return pud_huge ...
    } else {
        pmd = pmd_offset(pud, address);
    }

    // Level 3: PMD (Page Middle Directory)
    if (pmd_none(*pmd)) {
        // 分配 PTE 页表(4KB 页持有 512 个 PTE)
        pte = pte_alloc_map(mm, pmd, address);
        if (unlikely(!pte))
            return VM_FAULT_OOM;
    } else if (unlikely(trans_huge)) {
        // PMD 级 2MB 或 1GB 大页
        return create_huge_pmd ...
    } else {
        pte = pte_offset_map(pmd, address);
    }

    // Level 4: PTE (Page Table Entry) —— 叶子节点
    return handle_pte_fault(vma, address, pte, pmd, flags, regs);
}

工程优化亮点

惰性分配(Lazy Allocation):内核不会预分配所有层级的页表。例如进程 mmaps 100GB 匿名内存但只访问 10MB,则内核只分配 10MB / 4KB × 8B = 20KB 的 PTE 页 —— 而不是 100GB / 4KB × 8B = 200MB。

PTE 页池化:内核预分配的 PTE 页保存在 per-CPU 缓存中。缺页中断时直接从缓存分配,避免伙伴分配器的加锁和页清零开销。

透明大页(THP)合成:在 THP defer+madvise 模式下,__handle_mm_fault 会优先检查周围 PTE 是否已分配。若是,则合成 PMD 级 2MB THP 映射;否则回退常规 4KB。

六、handle_pte_fault:叶子级分支决策

handle_pte_fault 是缺页中断类型的决策核心:

c
static vm_fault_t handle_pte_fault(struct vm_area_struct *vma,
                                   unsigned long address, pte_t *pte,
                                   pmd_t *pmd, unsigned long flags,
                                   struct pt_regs *regs)
{
    // 情况 1:PTE 完全为空
    if (!pte_present(*pte)) {
        // A. 页未被映射 —— 处理文件映射的按需分页
        if (!vma->vm_file) {
            if (vma->vm_ops && vma->vm_ops->fault)
                return do_linear_fault(vma, address, flags, regs);  // 文件映射
            else
                return do_anonymous_fault(vma, address, flags, regs); // 匿名映射
        }

        // B. 页已在交换空间 —— 需要加载
        if (!pte_present(*pte) && pte_swp_soft_dirty(*pte))
            return do_swap_page(vma, address, flags, regs);  // 软脏页
        if (!pte_present(*pte) && is_swap_pte(*pte))
            return do_swap_page(vma, address, flags, regs);  // swap 区页
    }

    // 情况 2:PTE 存在但写保护(CoW fork)
    entry = *pte;
    if (flags & FAULT_FLAG_WRITE) {
        if (!pte_write(entry)) {
            // CoW:写只读 PTE → 复制页
            return do_wp_page(vma, address, pte, pmd, flags, regs);
        } else {
            // 硬件脏:设置脏位和访问位
            entry = pte_mkwrite(entry);
            entry = pte_mkyoung(entry);
        }
        // 原子更新 PTE 以避免竞态
        set_pte_at_notify(mm, address, pte, entry);
        flush_tlb_page(vma, address);
        return VM_FAULT_WRITE;
    }

    // 情况 3:PTE 存在且权限 OK —— 仅设置硬件访问位
    pte_mkyoung(*pte);   // 设置 Accessed 位
    if (!pte_dirty(*pte) && (vm_flags & VM_WRITE))
        pte_mkdirty(*pte); // 设置 Dirty 位

    return VM_FAULT_MINOR;
}

6.1 do_wp_page:写时复制的性能关键

do_wp_page 处理 fork() 后对只读共享页的写缺页中断:

c
static vm_fault_t do_wp_page(struct vm_area_struct *vma,
                             unsigned long address, pte_t *pte,
                             pmd_t *pmd, unsigned long flags,
                             struct pt_regs *regs)
{
    page = vm_normal_page(vma, address, *pte);

    // 快速路径 1:引用计数为 1,无其他进程共享
    //   直接归还原始页 —— 无需复制(90% 场景)
    if (page_count(page) == 1) {
        set_pte_at(mm, address, pte, pte_mkwrite(*pte));
        return VM_FAULT_WRITE;
    }

    // 快速路径 2:KSM(Kernel Samepage Merging)合并页
    //   顺带检查 —— 可能 refcount > 1 但内容相同
    if (PageKsm(page)) {
        // 触发与不同内容的并发合并 → 等待合并完成
        ...
    }

    // 慢路径:真正复制
    //   分配新页 + 从旧页复制页内容 + 设置新 PTE
    new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address);
    copy_user_highpage(new_page, page, address, vma);
    __SetPageUptodate(new_page);

    // 临界区:持有 PTL 锁 + 验证旧 PTE 未变化
    *ptpte = pte_offset_map_lock(mm, pmd, address, &ptl);
    if (likely(pte_same(*ptpte, *pte))) {
        // 对接 rmap 映射集 → 逐一更新
        page_add_new_anon_rmap(new_page, vma, address, false);
        // 递减旧页引用计数
        page_remove_rmap(page, false);
        put_page(page);
        set_pte_at_notify(mm, address, ptpte,
            mk_pte(new_page, vma->vm_page_prot));
    }
    pte_unmap_unlock(ptpte, ptl);

    return VM_FAULT_WRITE;
}

为什么 page_count == 1 能走快速路径:fork() 后该进程持有引用。如果没有其他进程(如 ptrace/gdb/strace)附加共享,引用计数恰好为 1 —— 此时该进程是此页面的唯一所有者,可直接将只读 PTE 升级为可写。这是 95% 的 fork+exec 进程的主要优化点,也是 Linux CoW 复制并不昂贵的原因。

6.2 do_swap_page:Swap-In 路径

当 PTE 内容为空但有有效的 swap 项时,需从交换区加载页:

c
static vm_fault_t do_swap_page(struct vm_area_struct *vma,
                               unsigned long address, pte_t *pte,
                               pmd_t *pmd, unsigned long flags,
                               struct pt_regs *regs)
{
    // 1. 解码 swap entry → 获取 swap 类型与 swap 偏移
    entry = pte_to_swp_entry(*pte);

    // 2. 检查 swap 缓存:页是否已加载且在 pagecache 中?
    page = lookup_swap_cache(entry, vma, address);
    if (page) {
        swap cache 命中:直接从 pagecache 使用
        ...
        goto out;
    }

    // 3. 检查并发 swap-in 以减少锁竞争
    //  (多线程对同一 entry 的并发 swap-in)
    if (swapcache_prepare(entry) != 1)
        goto out_noswap;

    // 4. 分配页并启动 I/O
    page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address);
    // 构建 bio → submit_bio → 从 swap 设备读页
    ret = swap_readpage(page, true, &plug);

    // 5. 在此处睡眠等待 I/O 完成
    //    注意:swap-in 期间持有 PTL 锁
    //    但是:进程状态被设为 TASK_KILLABLE
    wait_on_page_locked(page);

    // 6. 验证 I/O 等待期间 swap entry 没有变化
    if (!same_swap_pte(*pte, entry))
        goto unlock;

    // 7. 设置新 PTE
    set_pte_at(mm, address, pte,
        mk_pte(page, vma->vm_page_prot));
out:
    swap_free(&entry);
    return ret;
}

七、Userfaultfd:用户态缺页处理的协同机制

userfaultfd 是 Linux 内核最独特的缺页中断扩展 —— 它允许用户程序在用户空间自定义缺页中断处理逻辑。这是 firecracker microVM 快照恢复、PostgreSQL 备份工具、CRIU 热迁移的核心机制。

工作原理

当 userfaultfd 注册一段地址范围后,内核在缺页时跳过常规处理路径,转而向用户进程发送事件:

c
// mm/userfaultfd.c
vm_fault_t handle_userfault(struct vm_fault *vmf, unsigned long reason)
{
    // 1. 若注册了 UFFD 的 Missing 事件 → 发送事件到用户空间
    // 2. 若注册了 UFFD 的 WP(写保护)事件 → 发送事件
    // 3. 内核将当前 task 设置为 TASK_KILLABLE 状态直到处理完成
    // 4. 线程在 ctx->fd_wqh 等待队列中睡眠

    // Userfaultfd 事件结构
    struct uffd_msg msg = {
        .event = UFFD_EVENT_PAGEFAULT,
        .arg.pagefault.address = address,
        .arg.pagefault.flags = 0,
    };

    // 将事件发送到用户空间
    __deliver_uffd_message(ctx, &msg);

    // 不处理事件的线程接收 SIGBUS 退出
    userfaultfd_wait_queue_remove(vmf);
    return VM_FAULT_RETRY;
}

用户进程随后调用 ioctl(UFFDIO_COPY) 或 ioctl(UFFDIO_ZEROPAGE) 向缺页页注入内容:

c
// 用户空间伪代码
struct uffdio_copy copy = {
    .dst = fault_address,           // 出错的虚拟地址
    .src = source_page,             // 源数据地址(如从网络接收或快照文件)
    .len = PAGE_SIZE,
    .mode = 0,
};
ioctl(uffd_fd, UFFDIO_COPY, ©); // 触发:内核填充页表并唤醒 faulting 线程

工程价值:UFFD 在关键生产场景中的独特优势

方案 UFFD 优势
预取(prefault) UFFD 仅对实际出错地址分配页 —— 无 I/O 膨胀
快照恢复 恢复仅在访问时执行,微秒级启动延时 —— 可控
热迁移 可承受 500MB/s 的脏页率,总迁移时间压降到 200ms 内
KV 存储(Redis) UFFD 捕获脏页用于 RDB 增量快照同步

八、缺页中断生产调优实战

8.1 识别缺页中断瓶颈

bash
# 1. 实时监控缺页中断率
vmstat 1
--------memory-- -----swap-- -----
  free  buff  cache   si   so
 ...

# 2. 每进程缺页中断统计
grep -E 'pswpin|pswpout|majflt|minflt' /proc/vmstat

# 3. Perf 工具精确计数
perf stat -e page-faults -e major-faults -e minor-faults -a sleep 10

# 4. FTrace 事件探针
echo 1 > /sys/kernel/debug/tracing/events/exceptions/page_fault_user/enable
echo 1 > /sys/kernel/debug/tracing/events/exceptions/page_fault_kernel/enable

8.2 关键调优参数

bash
# 1. fault-around 内核参数(预读页数量)
#    适用:顺序扫描(DuckDB Parquet 文件),增大会提升吞吐量
echo 256 > /sys/kernel/mm/fault_around_bytes

# 2. THP 透明大页
echo "madvise" > /sys/kernel/mm/transparent_hugepage/enabled
echo "defer" > /sys/kernel/mm/transparent_hugepage/defrag

# 3. 应用层调优(大型 OLAP 分析、pg_prewarm)
pg_prewarm('my_table');  // 手动将表页预加载到内存

# 4. Swap 调优(防止大规模 swap thrashing)
echo 10 > /proc/sys/vm/swappiness        // 降低 swappiness = 内存压力下减少 swap
echo 1 > /proc/sys/vm/compact_memory     // 触发内存碎片整理

# 5. NUMA 按节点内存调优
sysctl -w vm.zone_reclaim_mode=1         // NUMA 优化:强制本地节点回收

8.3 案例:ClickHouse 缺页中断调优

ClickHouse MergeTree 引擎在 OLAP 查询时会对合并数据文件执行大量随机读取,典型瓶颈栈:


查询 → 内存映射合并数据文件 → 随机 4KB 缺页中断 → 内核预读脏页 → 90% I/O 浪费

经过以下调整后:

bash
# 1. 最小化 fault-around(消除预读污染)
sysctl vm.fault_around_bytes=4096

# 2. 应用层使用 madvise 配合 hugepages(减少 TLB 未命中 512 倍)
madvise(addr, length, MADV_HUGEPAGE);

# 3. 使用 madvise 手动预取查询影响的所有区域
madvise(addr, length, MADV_WILLNEED);  // 内核异步预读

# 4. 使用 userfaultfd 协调快照加载
// 进程启动时不再 mmap+mlockall 1TB → 改用 UFFD 按需加载

效果:
- 查询 P99 延迟:850ms → 120ms(7.1 倍提升)
- 存储 I/O 浪费:88% → 3.2%
- 内存 RSS:1.2TB → 340GB

九、NUMA 平衡:缺页中断驱动的自动内存迁移

Linux 3.13 引入内核 NUMA 平衡,利用缺页中断方式检测并迁移需要移到本地 NUMA 节点的热页,核心机制:

c
// mm/memory.c
void task_numa_fault(unsigned long address, int node, int pages, int flags)
{
    // 1. 内核将 PTE 标记为 "NUMA hinting fault" → 设置隐藏的 "young" 位
    // 2. mprotect() 周期性批量扫描会清除 PTE 来跟踪访问模式
    // 3. 若发现页被本地进程访问但位于远端节点 → 触发迁移
    if (numa_migrate_prep(page, vma, address)) {
        migrate_misplaced_page(page, vma, address, node);
    }
}

与缺页中断的协同:在 do_anonymous_page 中,内核分配页时主动使用本地 NUMA 节点内存。已有页的热热度通过 task_numa_scan 周期性扫描 PTE 的硬件访问位来判断 —— 缺页中断产生的页面获得扫描优先级。这种"缺页中断 + 页表遍历 + 内存迁移"的多维协同设计,体现了 Linux 内核内存管理工程的综合实力。

十、总结:缺页中断工程的统一视角

缺页中断是连接虚拟内存理论与真实硬件执行的"最后一条关键路径"。深入这条路径,可得到四条工程启示:

1. 快速路径优先,慢速路径守卫:fixup_exception → find_vma → handle_mm_fault 三层快速决策树,为 90% 的场景守住快速执行

2. 零页复用,按需分配 + CoW:零页映射、PTE 级 CoW 等惰性策略通过结构性共享节约内存

3. 架构一致,快速路径专精:x86 CR2 快速读取 与 arm64 ESR 多比特解码,但在 __handle_mm_fault 层面统一 —— 架构抽象层应放在正确的位置

4. 硬软件协同设计:fault-around、THP 合成、NUMA 平衡均与硬件页表遍历器和 TLB 阴影深度耦合

在生产环境中,理解缺页中断路径能让内存密集型负载(数据库、AI 推理、大数据分析)的调优从"随机摸索"变为"有据可依" —— 多一次缓存未命中或不必要的同步原语都会被放大成 P99 延迟爆炸。从 entry_64.S:page_fault 到 memory.c:handle_pte_fault 的内核代码,值得每一个内存应用开发者反复研读和基准测试。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部