概述
本文档详细说明了 Linux 内核中 Fork 系统调用的 Copy-on-Write (COW) 机制实现原理,包括内存页面共享、写时复制触发流程,以及在 Shared Virtual Memory (SVM) 环境中与 MMU Notifier 的协作机制。
1. Fork 的基本原理
当调用 fork() 系统调用时,操作系统创建一个子进程,它是父进程的几乎完全拷贝:
父进程内存布局 Fork后
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 代码段 │ │ 代码段 │ │ 代码段 │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ 数据段 │ ────────> │ 数据段 │ │ 数据段 │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ 堆 │ │ 堆 │ │ 堆 │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ 栈 │ │ 栈 │ │ 栈 │
└─────────────┘ └─────────────┘ └─────────────┘
父进程 子进程
子进程获得:
- 独立的进程 ID
- 相同的内存映射(虚拟地址空间)
- 相同的打开文件描述符
- 相同的寄存器状态
为了优化性能,Linux 内核并不会立即复制所有内存页,而是作了如下处理:
-
物理页面共享 :父子进程的虚拟地址都映射到相同的物理页面
-
页表项标记为只读:即使原本是可写的页面,也被标记为只读(写保护)
-
设置 COW 标志:在页表项或相关数据结构中设置特殊标志,表示这是 COW 页面
物理内存 页表映射
┌─────────────┐ 父进程页表 子进程页表
│ 物理页 A │ <───────── │ │
│ (共享页面) │ │ │
└─────────────┘ ↓ ↓
虚拟地址 虚拟地址
(只读,COW) (只读,COW)
这种设计使得 fork 操作非常快速,因为不需要复制任何内存页,只需要复制页表结构并调整页表项的权限。
2. Copy-on-Write (COW) 优化机制
2.1 为什么需要 COW?
如果 fork 时立即复制所有内存页:
- 效率低:大量内存复制操作耗时
- 浪费空间:很多页面可能永远不会被修改
- 不必要:只读的页面无需复制
COW 机制的优化策略:延迟复制,只在需要时才复制。
2.2 COW 的工作机制
下面按阶段分析下页表的变化。
阶段 1:Fork 时刻
物理内存 页表映射
-------------------------------------------------
┌─────────────┐ 父进程页表 子进程页表
│ 物理页 A │ <───────── │ │
│ (0xdeadbeef)│ │ │
└─────────────┘ ↓ ↓
虚拟地址 虚拟地址
(只读) (只读)
Fork 后发生的变化:
- 物理页面共享 :父子进程的虚拟地址都映射到相同的物理页面
- 页表项标记为只读:即使原本是可写的页面,也被标记为只读(写保护)
- 设置 COW 标志:在页表项或相关数据结构中设置特殊标志,表示这是 COW 页面
阶段 2:写入触发 COW
当任一进程(父或子)尝试写入共享页面时:
写入操作
↓
1. CPU 检测到写入只读页面
↓
2. 触发页面错误(Page Fault)
↓
3. 内核异常处理程序检查:是 COW 页面?
↓
4. 是:执行 COW 处理流程
├─ 分配新的物理页面
├─ 复制原页面内容到新页面
├─ 更新写入进程的页表,指向新页面
├─ 将新页面标记为可写
└─ 调用 MMU Notifier 回调(通知相关子系统)
↓
5. 返回用户空间,重新执行写入指令
↓
6. 写入成功完成
阶段 3:COW 后的内存状态
┌─────────────┐ ┌─────────────┐
│ 旧物理页 A │ │ 新物理页 B │
│ (0xdeadbeef)│ │ (0xD00BED00)│
└─────────────┘ └─────────────┘
↑ ↑
│ │
未写入进程 写入进程
页表(只读,COW) 页表(可写,非COW)
3. 在 SVM 环境中的挑战
3.1 问题描述
在 Shared Virtual Memory (SVM) 系统中:
- CPU 和 GPU 共享相同的虚拟地址空间
- GPU 有自己的页表(GPU MMU)
- GPU 页表需要与 CPU 页表保持同步
当 COW 发生时:
CPU 页表:虚拟地址 0x1000 → 新物理页 B (0xD00BED00)
GPU 页表:虚拟地址 0x1000 → 旧物理页 A (0xdeadbeef) ❌ 不一致!
3.2 MMU Notifier 机制
Linux 内核提供 MMU Notifier 机制来解决这个问题:
┌──────────────────────────────────────────────────────┐
│ Linux 内核 │
│ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ 内存管理子系统│ ─────> │ MMU Notifier │ │
│ │ (MM) │ 通知 │ 回调接口 │ │
│ └─────────────┘ └──────────────────┘ │
│ │ │ │
│ │ COW 事件 │ 回调 │
│ ↓ ↓ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ 更新 CPU │ │ GPU 驱动程序 │ │
│ │ 页表 │ │ (amdgpu) │ │
│ └─────────────┘ └──────────────────┘ │
└──────────────────────────────────┬───────────────────┘
│
↓
┌──────────────────┐
│ 更新 GPU 页表 │
│ (GPU MMU) │
└──────────────────┘
MMU Notifier 的关键回调函数
invalidate_range_start:在内核即将修改页表前调用invalidate_range_end:在内核完成页表修改后调用change_pte:当页表项改变时调用
GPU 驱动注册这些回调,在 COW 发生时更新 GPU 的页表映射。
4. MMU Notifier 实现要点
4.1 驱动注册
GPU 驱动需要注册 MMU Notifier:
c
// 伪代码示例
struct mmu_notifier_ops amdgpu_mn_ops = {
.invalidate_range_start = amdgpu_mn_invalidate_range_start,
.invalidate_range_end = amdgpu_mn_invalidate_range_end,
};
// 注册 notifier
mmu_notifier_register(&amdgpu_mn, mm);
4.2 回调处理
c
// 伪代码示例
static int amdgpu_mn_invalidate_range_start(
struct mmu_notifier *mn,
const struct mmu_notifier_range *range)
{
// 1. 确定受影响的虚拟地址范围
// 2. 查找相关的 GPU 映射
// 3. 使现有 GPU TLB 条目失效
// 4. 等待 GPU 完成相关操作
// 5. 标记映射需要更新
return 0;
}
static void amdgpu_mn_invalidate_range_end(
struct mmu_notifier *mn,
const struct mmu_notifier_range *range)
{
// 1. 获取新的物理地址映射
// 2. 更新 GPU 页表
// 3. 刷新 GPU TLB
}
4.3 关键考虑因素
- 同步:确保 GPU 操作完成后才更新页表
- 性能:最小化 GPU 停顿时间
- 粒度:只更新受影响的页表条目
- 错误处理:处理页表更新失败的情况
5. SVM 一致性的实现
5.1 内存一致性模型
在 SVM 系统中,CPU 和 GPU 必须看到一致的内存视图:
虚拟地址空间 (CPU & GPU 共享)
│
↓
┌─────────────────────────────┐
│ 虚拟地址 0x1000 │
└─────────────────────────────┘
│ │
CPU 访问 GPU 访问
│ │
↓ ↓
┌──────────┐ ┌──────────┐
│ CPU 页表 │ │ GPU 页表 │
└──────────┘ └──────────┘
│ │
└─────┬─────┘
↓
必须指向相同的物理页面
↓
┌─────────────┐
│ 物理页 B │
│(0xD00BED00) │
└─────────────┘
5.2 MMU Notifier 保证的属性
- 虚拟地址透明性:应用程序使用统一的虚拟地址
- 物理映射一致性:CPU 和 GPU 访问相同的物理内存
- 自动同步:内核自动处理页表变化
- 进程隔离:不同进程的 SVM 空间相互独立
5.3 COW 与 MMU Notifier 协作流程
当 COW 在 SVM 环境中发生时,完整的处理流程如下:
用户进程写入操作
↓
触发 Page Fault
↓
┌───────────────────────────────────────┐
│ 内核页面错误处理程序 │
│ │
│ 1. 检查错误类型:写入只读页面? │
│ 2. 检查页面标志:是 COW 页面? │
│ 3. 是:开始 COW 处理 │
└───────────────────────────────────────┘
↓
┌───────────────────────────────────────┐
│ COW 处理流程 │
│ │
│ 1. 分配新物理页面 │
│ 2. 复制原页面内容到新页面 │
│ 3. 调用 MMU Notifier 回调 │
│ └─> invalidate_range_start() │
│ 4. 更新进程页表 PTE │
│ 5. 将新页面标记为可写 │
│ 6. 调用 MMU Notifier 回调 │
│ └─> invalidate_range_end() │
└───────────────────────────────────────┘
↓ ↓
返回用户空间 GPU 驱动回调
重新执行写入 ↓
↓ ┌──────────────────┐
写入成功完成 │ 1. 查找 GPU 映射 │
│ 2. 失效 GPU TLB │
│ 3. 更新 GPU 页表 │
│ 4. 刷新 GPU TLB │
└──────────────────┘
5.4 关键时序点
-
Invalidate Range Start:
- 时机:在修改 CPU 页表之前
- 作用:通知 GPU 驱动页面即将改变,暂停相关 GPU 操作
- 要求:必须等待 GPU 完成当前对该页面的访问
-
更新 CPU 页表:
- 时机:在两次 notifier 回调之间
- 作用:将虚拟地址映射到新的物理页面
- 状态:此时 CPU 页表已更新,但 GPU 页表可能尚未更新
-
Invalidate Range End:
- 时机:在修改 CPU 页表之后
- 作用:通知 GPU 驱动更新完成,可以更新 GPU 页表
- 要求:必须在 GPU 下次访问之前完成页表更新
5.5 内核实现细节
页面错误处理路径如下:
do_page_fault() // arch/x86/mm/fault.c
↓
handle_mm_fault() // mm/memory.c
↓
__handle_mm_fault()
↓
handle_pte_fault()
↓
do_wp_page() // 写保护页面错误处理
↓
wp_page_copy() // COW 实际实现
↓
├─ 检查页面引用计数
├─ 引用计数 > 1: 需要复制
│ ├─ alloc_page() // 分配新页面
│ ├─ copy_user_highpage() // 复制内容
│ ├─ mmu_notifier_invalidate_range_start()
│ ├─ set_pte_at() // 更新页表项
│ ├─ page_remove_rmap() // 解除旧页面映射
│ ├─ page_add_new_anon_rmap() // 添加新页面映射
│ └─ mmu_notifier_invalidate_range_end()
└─ 引用计数 = 1: 直接标记为可写
6. 总结
关键要点有:
-
Fork + COW:Linux 标准的进程创建优化机制
- 延迟复制,提高 fork 性能
- 节省内存,只复制实际修改的页面
-
物理页面共享:Fork 后父子进程共享物理页面
- 通过页表项的写保护实现
- 使用引用计数管理页面生命周期
-
写时复制触发:首次写入时才分配新物理页面
- Page Fault 机制触发
- 内核自动处理,对应用透明
-
MMU Notifier:内核机制,通知外部子系统页表变化
- 双回调设计:start 和 end
- 保证外部页表与 CPU 页表同步
-
GPU 页表同步:GPU 驱动通过 MMU Notifier 更新 GPU 页表
- 失效旧映射,建立新映射
- 刷新 GPU TLB,保证一致性
-
SVM 一致性:确保 CPU 和 GPU 访问相同的物理内存
- 统一虚拟地址空间
- 自动的页表同步机制
本篇作为引子,来引出MMU notifier在SVM中的应用,接下来我会详细分析MMU notitifer的实现。SVM的专栏实现在2026年的2月专栏里分析。敬请持续关注我的博客。
🔗 导航
- 下一章 : 第1章: MMU Notifier的背景与问题域
- 返回目录 : MMU Notifier 机制与应用系列目录