夜雨聆风学习资料网

ARTICLE · 1292691

Linux 6.12 源码深度剖析: wp_page_copy

Linux 6.12 源码深度剖析: wp_page_copy

Linux 6.12 内核深度解析:写时复制(COW)核心函数 wp_page_copy

📌 技术点速览

        在 Linux 内核中,写时复制(Copy-on-Write, COW)是实现进程高效隔离与内存节约的核心机制。当父进程通过 fork() 创建子进程时,双方共享相同的物理内存页,且这些页在页表(PTE)中被标记为“只读”。一旦任意进程尝试写入这些共享页,CPU 就会触发写保护缺页异常(Write-Protect Page Fault)。

  wp_page_copy 函数正是用于解决写保护缺页异常的核心实现。它位于虚拟内存管理的核心路径上,负责分配新的物理页、拷贝旧页数据、更新页表、处理反向映射(rmap)并刷新 TLB。该函数隶属于 MemoryManagement (内存管理系统) 模块,是连接虚拟内存管理、物理内存分配器以及底层硬件 MMU 的关键纽带。


🗺️ 软件功能架构图

        以下是 wp_page_copy 在 Linux 内核中的调用上下文及跨模块协同架构图。图中清晰展示了从硬件异常触发,到内存管理系统内部各子模块协同,再到硬件 MMU 刷新的完整生命周期。


🔍 核心源码硬核解析 (基于 Linux 6.12)

在 Linux 6.12 中,wp_page_copy 的实现位于 mm/memory.c。以下是该函数的完整核心源码,伴有极其详尽的逐行中文注释与架构级原理解释。

staticvm_fault_twp_page_copy(struct vm_fault *vmf){    /* 1. 解析缺页上下文 */    const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE; // 是否仅执行解除共享(如 mprotect 或 io_uring 触发)    struct vm_area_struct *vma = vmf->vma;                // 获取当前虚拟内存区域 (VMA)    struct mm_struct *mm = vma->vm_mm;                    // 获取当前进程的内存描述符    struct folio *old_folio = NULL;                       // 指向写保护的旧物理页 (以 folio 组织)    struct folio *new_folio = NULL;                       // 指向即将分配的新物理页    pte_t entry;                                          // 新的页表项 (PTE)    int page_copied = 0;                                  // 标记是否成功完成了物理页拷贝    struct mmu_notifier_range range;                      // 用于通知外部 MMU (如 KVM, IOMMU) 的范围结构体    vm_fault_t ret;    bool pfn_is_zero;                                     // 标记旧页是否为系统全局零页 (Zero Page)    delayacct_wpcopy_start();                             // 开启写时复制延迟统计 (用于 taskstats)    if (vmf->page)        old_folio = page_folio(vmf->page);                // 获取旧页对应的 folio 结构    /* 2. 准备匿名映射上下文 */    ret = vmf_anon_prepare(vmf);                          // 确保 VMA 已经关联了 anon_vma 结构,用于反向映射    if (unlikely(ret))        goto out;    /* 3. 优化判断:如果旧页是全局零页,则无需实际拷贝,直接分配一个全零的新页即可 */    pfn_is_zero = is_zero_pfn(pte_pfn(vmf->orig_pte));    /*      * 4. 关键设计:在未持有页表锁 (PTL) 的情况下预分配新页。     * 内存分配(可能触发内存回收、压缩甚至 OOM)是高延迟操作。     * 绝不能在持有页表锁的情况下调用,否则会严重阻塞其他线程的页表访问。     */    new_folio = folio_prealloc(mm, vma, vmf->address, pfn_is_zero);    if (!new_folio)        goto oom;    /* 5. 执行数据拷贝 */    if (!pfn_is_zero) {        int err;        /* 将旧页的数据拷贝到新分配的物理页中 */        err = __wp_page_copy_user(&new_folio->page, vmf->page, vmf);        if (err) {            /*              * 如果拷贝失败(例如遭遇硬件 MCE 内存损坏 -EHWPOISON),             * 则释放新页,并根据错误类型决定是否向用户空间发送信号。             */            folio_put(new_folio);            if (old_folio)                folio_put(old_folio);            delayacct_wpcopy_end();            return err == -EHWPOISON ? VM_FAULT_HWPOISON : 0;        }        kmsan_copy_page_meta(&new_folio->page, vmf->page); // KMSAN (KernelMemorySanitizer) 元数据拷贝    }    __folio_mark_uptodate(new_folio);                     // 标记新页数据已是最新的 (Uptodate)    /* 6. 初始化 MMU 通知器,准备使外部页表失效(如 GPU 或 KVM 虚拟机页表) */    mmu_notifier_range_init(&range, MMU_NOTIFY_CLEAR, 0, mm,                vmf->address & PAGE_MASK,                (vmf->address & PAGE_MASK) + PAGE_SIZE);    mmu_notifier_invalidate_range_start(&range);    /*     * 7. 核心同步点:重新获取页表锁 (PTL) 并执行“双重检查锁 (Double-Checked Locking)”     * 因为在 `folio_prealloc` 和 `__wp_page_copy_user` 期间,我们没有持有页表锁,     * 另一个内核线程可能已经并发处理了该缺页异常并修改了此 PTE。     */    vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);    /* 验证当前的 PTE 是否与触发异常时的 orig_pte 完全一致 */    if (likely(vmf->pte && pte_same(ptep_get(vmf->pte), vmf->orig_pte))) {        if (old_folio) {            /* 如果旧页不是匿名页(例如是只读的文件页映射),需要调整内存计数器 */            if (!folio_test_anon(old_folio)) {                dec_mm_counter(mm, mm_counter_file(old_folio));                inc_mm_counter(mm, MM_ANONPAGES);            }        } else {            /* 如果是零页,可能需要解除 KSM (Kernel Shared Memory) 零页映射 */            ksm_might_unmap_zero_page(mm, vmf->orig_pte);            inc_mm_counter(mm, MM_ANONPAGES);        }        /* 刷新架构相关的 CPU Cache (主要针对 VIVT 架构,现代 x86/arm64 多为 PIPT,此函数多为空) */        flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));        /* 8. 构造新的 PTE 属性 */        entry = mk_pte(&new_folio->page, vma->vm_page_prot); // 根据 VMA 属性创建基础 PTE        entry = pte_sw_mkyoung(entry);                       // 标记该页已被访问 (Young)        if (unlikely(unshare)) {            /* 如果仅仅是解除共享,保留原有的脏页和 uffd 标记,但不赋予写权限 */            if (pte_soft_dirty(vmf->orig_pte))                entry = pte_mksoft_dirty(entry);            if (pte_uffd_wp(vmf->orig_pte))                entry = pte_mkuffd_wp(entry);        } else {            /* 正常 COW 路径:赋予写权限 (Writable) 并标记为脏页 (Dirty) */            entry = maybe_mkwrite(pte_mkdirty(entry), vma);        }        /*         * 9. 极其关键的安全设计:先清除并刷新旧的 PTE,再写入新的 PTE。         * 必须使用 `ptep_clear_flush` 强行同步所有 CPU 的 TLB。         * 如果先写新 PTE 再刷 TLB,会产生一个短暂的窗口期:         * 此时 CPU A 已经加载了新 PTE(可写),而 CPU B 仍在使用旧 TLB(只读但指向旧物理页)。         * 如果此时旧物理页的引用计数被释放并被其他进程复用,CPU B 就会非法读取/写入其他进程的数据!         */        ptep_clear_flush(vma, vmf->address, vmf->pte);        /* 10. 建立新页的反向映射 (RMAP) */        /* 标记该新页为当前进程独占 (RMAP_EXCLUSIVE),并加入匿名页反向映射系统 */        folio_add_new_anon_rmap(new_folio, vma, vmf->address, RMAP_EXCLUSIVE);        folio_add_lru_vma(new_folio, vma); // 将新页挂入 LRU 链表,使其受内存回收机制管理        BUG_ON(unshare && pte_write(entry));        /* 11. 将新 PTE 写入硬件页表 */        set_pte_at(mm, vmf->address, vmf->pte, entry);        /* 更新架构相关的 MMU Cache (例如在 arm64 上可能用于预装载 TLB) */        update_mmu_cache_range(vmf, vma, vmf->address, vmf->pte, 1);        if (old_folio) {            /*             * 12. 解除旧页的反向映射。             * 必须在页表完全切换(set_pte_at)且 TLB 刷新(ptep_clear_flush)之后,             * 才能安全地减少旧页的 mapcount。             * 否则,其他 CPU 可能在 mapcount 归零、旧页被释放复用后,依然通过旧 TLB 写入该页。             */            folio_remove_rmap_pte(old_folio, vmf->page, vma);        }        /* 成功替换,将 new_folio 指向 old_folio,以便在函数末尾统一释放旧页的引用计数 */        new_folio = old_folio;        page_copied = 1;        pte_unmap_unlock(vmf->pte, vmf->ptl); // 释放页表锁    } else if (vmf->pte) {        /* 双重检查失败:说明其他线程已经抢先完成了缺页处理 */        update_mmu_tlb(vma, vmf->address, vmf->pte);        pte_unmap_unlock(vmf->pte, vmf->ptl);    }    /* 13. 结束 MMU 通知器区间 */    mmu_notifier_invalidate_range_end(&range);    /* 14. 统一的资源清理 */    if (new_folio)        folio_put(new_folio); // 如果发生了并发竞争导致放弃,或者成功替换,此处释放对应的 folio 引用    if (old_folio) {        if (page_copied)            free_swap_cache(old_folio); // 如果成功拷贝,尝试从 swap cache 中释放旧页        folio_put(old_folio);           // 递减旧页的引用计数    }    delayacct_wpcopy_end();    return 0;oom:    ret = VM_FAULT_OOM;out:    if (old_folio)        folio_put(old_folio);    delayacct_wpcopy_end();    return ret;}

🛠️ 核心职责体现

wp_page_copy 的代码设计完美体现了 MemoryManagement (内存管理系统) 的三大核心职责:

  1. 并发安全与锁优化:采用“无锁预分配 + 双重检查锁(Double-Checked Locking)”机制。在不持有页表锁(PTL)的情况下进行高延迟的物理页分配(folio_prealloc)和数据拷贝(__wp_page_copy_user),最大化减少了 PTL 的持锁时间,避免了多线程并发缺页时的锁崩溃。

  2. 严格的内存一致性(TLB 顺序):通过“先清除旧 PTE 并刷新 TLB(ptep_clear_flush),再写入新 PTE(set_pte_at),最后递减旧页反向映射计数(folio_remove_rmap_pte)”的严密顺序,彻底杜绝了多核架构下的时序竞争(Race Condition)和潜在的内存越界/脏数据泄露漏洞。

  3. 生命周期与反向映射管理:通过 folio_add_new_anon_rmap 和 folio_remove_rmap_pte 维护了精确的物理-虚拟映射关系,确保了内存回收系统(Reclaim)在面临内存压力时,能够准确找到并回收这些匿名页。


⚙️ 系统运行背景与上下文

  wp_page_copy 绝非孤立运行,它处于 Linux 内核多个核心子系统交汇的十字路口。

1. 中断/异常子系统与硬件 MMU 的协同

        当用户态进程尝试写入一个被标记为“只读”的页时:

  • 硬件层面:CPU 的 MMU 发现页表项中的 Writable 位为 0,立即触发 Page Fault 异常(x86 下为 #PF 异常,arm64 下为 Data Abort 异常,异常状态码 FSC 指示为 Permission Fault)。

  • 架构相关处理:CPU 自动跳转到内核的异常向量表。在 arm64 上,执行 el1_da 或 el0_da,最终调用 do_page_fault();在 x86 上,调用 exc_page_fault()。

  • 通用内存管理:异常处理程序读取控制寄存器(x86 的 CR2,arm64 的 FAR_EL1)获取缺页的虚拟地址,并封装为 vm_fault 结构体,向下传递给 handle_mm_fault() -> do_wp_page(),最终进入 wp_page_copy()。

2. 进程调度器(Scheduler)的跨模块交互

        在 wp_page_copy 执行过程中,可能会与调度器发生深度交互:

  • 主动让出 CPU:folio_prealloc() 内部调用伙伴系统分配器。如果系统物理内存极度紧张,分配器会进入“直接内存回收(Direct Reclaim)”或“内存压缩(Memory Compaction)”路径。这些路径涉及磁盘 I/O(Swap Out)或等待页面锁,会调用 schedule() 将当前进程挂起,让出 CPU。

  • 延迟统计(Delay Accounting):代码前后的 delayacct_wpcopy_start() 和 delayacct_wpcopy_end() 会向调度器的任务统计模块(Taskstats)汇报当前进程在写时复制上消耗的时间,作为系统性能调优(如 iotop、vmstat)的数据源。

3. 架构相关 MMU 抽象层的差异化实现

wp_page_copy 内部的页表操作和 TLB 刷新在不同硬件架构上有着截然不同的底层行为:

操作函数
x86-64 架构实现
arm64 架构实现
ptep_clear_flush
1. 清除本地 PTE。 2. 触发 SMP IPI(处理器间中断),广播给所有运行该进程地址空间的 CPU 核心。 3. 接收端 CPU 执行 invlpg 指令刷新本地 TLB。 4. 高延迟,存在 IPI 暴风雨风险。
1. 清除本地 PTE。 2. 执行 tlbi (TLB Invalidate) 硬件广播指令(如 tlbi vae1is)。 3. 硬件总线自动同步所有核心的 TLB,无需软件 IPI 中断。 4. 低延迟,硬件级高并发。
set_pte_at
直接通过内存屏障指令(如 barrier())写入内存,x86 的强内存模型(TSO)保证了写入顺序。
写入页表描述符,并必须执行 dsb ishst(数据同步屏障)指令,确保页表写入在 MMU 硬件下一次翻译前对全局可见。

💡 10年老兵避坑指南/实战案例

        在超大规模高并发的生产环境中,写时复制(COW)机制常常成为系统性能瓶颈的幕后黑手。以下结合一个真实的生产案例,剖析因不理解 wp_page_copy 机制导致的系统崩溃及调优方案。

🚨 生产案例:Redis 宿主机 CPU 突发暴涨与业务超时

1. 背景与现象

        某核心 Redis 缓存集群,在每天下午进行 RDB 自动快照(bgsave)时,宿主机 CPU 使用率会瞬间飙升至 90% 以上,伴随 Redis 响应延迟(P99)从 1ms 暴涨至 500ms 以上,导致下游大量微服务出现连接超时。

2. 根因剖析

  1. fork() 触发全量只读映射:Redis 执行 bgsave 时,会 fork() 一个子进程。fork 瞬间,父子进程的所有虚拟内存页(可能高达数十 GB)在页表中全部被标记为只读。

  2. 高频写操作触发 COW 暴风雨:Redis 作为缓存,承载着极高并发的写请求。每当有写请求到达,CPU 就会触发写保护缺页异常,疯狂调用 wp_page_copy。

  3. TLB 刷新的 IPI 锁死多核(x86 架构特有): 在 x86 架构下,wp_page_copy 内部的 ptep_clear_flush 会向所有运行该 Redis 进程的 CPU 核心发送 IPI 中断以刷新 TLB。 在高并发写、大内存页表的场景下,成千上万个 wp_page_copy 并发执行,导致系统充斥着 IPI 广播暴风雨。CPU 核心几乎所有的时间都浪费在响应 IPI 中断和等待页表锁(PTL)上,导致系统陷入假死状态。

3. 诊断排查方法

Step 1: 使用 perf 抓取内核热点

    在问题发生时,执行以下命令抓取 CPU 热点:

perf record -g -p <redis_pid> -- sleep 10perf report -g --hierarchical

    异常热点输出示例:

- 85.40%  [kernel]  - 62.10%  do_page_fault    - 58.30%  handle_mm_fault      - 55.10%  do_wp_page        - 52.40%  wp_page_copy          + 38.20%  ptep_clear_flush          + 10.10%  __wp_page_copy_user

    可以看到,超过 50% 的 CPU 时间被消耗在 wp_page_copy 及其内部的 ptep_clear_flush(TLB 刷新)上。

Step 2: 监控系统中断变化

通过监控 /proc/interrupts 中的 CAL(Function call interrupts,在 x86 上常用于 TLB 刷新的 IPI 广播)增长速率:

watch -d "cat /proc/interrupts | grep CAL"

在 Redis bgsave 期间,CAL 中断的每秒增长量达到了数十万次,证实了 IPI 暴风雨的存在。

4. 终极调优与避坑解决方案

方案 A:禁用透明大页(Transparent Huge Pages, THP)

       虽然大页可以减少页表项数量,但在 COW 场景下,THP 是灾难性的。 如果启用了 THP,当写入一个 2MB 的大页中的任意一个字节时,wp_page_copy 必须分配并拷贝一整个 2MB 的物理页,而不是普通的 4KB 页。这会导致拷贝延迟(__wp_page_copy_user)暴增 512 倍,并极易引发严重的内存碎片和 OOM。

修复命令:

echo never > /sys/kernel/mm/transparent_hugepage/enabledecho never > /sys/kernel/mm/transparent_hugepage/defrag
方案 B:利用 MADV_DONTFORK 优化非必要内存

        对于 Redis 内部的一些非关键大内存缓冲区(如客户端输入输出缓冲区、复制积压缓冲区 repl-backlog-size),这些数据不需要被写入 RDB 快照。 可以通过修改 Redis 源码或使用动态链接库拦截 malloc,对这些区域调用 madvise:

madvise(addr, length, MADV_DONTFORK);

        被标记为 MADV_DONTFORK 的虚拟内存区域在 fork() 时不会被子进程继承,从而彻底免除了这部分内存的写时复制开销。

方案 C:调整内核参数减少页表锁竞争

        在多线程应用中,可以通过启用页表锁拆分(Split Page Table Locks)来缓解 PTL 锁竞争。确保内核编译选项开启了 CONFIG_SPLIT_PTE_PTL(Linux 6.12 默认对 4KB 页开启)。对于 PMD 级别的大页锁,确保 CONFIG_ARCH_ENABLE_SPLIT_PMD_PTL 处于开启状态。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料