Linux内核分析之进程管理-02
10.1 fork() —— 写时复制的基础
fork() 是 UNIX 系统中最古老的系统调用之一。它的语义简洁而优雅:创建调用进程的精确副本。子进程获得父进程地址空间、文件描述符、信号处理器等所有资源的独立拷贝,但拥有自己的 PID。fork() 最令人着迷的特性是它 "返回两次" —— 在父进程中返回子进程的 PID,在子进程中返回 0。本节将深入分析 fork() 在 Linux 7.0.10 内核中的完整实现路径。
10.1.1 fork() 系统调用入口
fork() 的系统调用实现极其简洁。在 kernel/fork.c 第 2732-2746 行,可以看到其完整定义:
// kernel/fork.c, line 2732
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
#endif
注意几个关键细节:
-
SYSCALL_DEFINE0(fork):fork 不接受任何参数。这是最简单的系统调用定义形式。SYSCALL_DEFINE0宏会展开为sys_fork()函数。 -
.exit_signal = SIGCHLD:fork() 唯一设置的参数是exit_signal,指定子进程退出时向父进程发送SIGCHLD信号。这是 fork() 与 clone() 的核心区别之一 —— clone() 允许自定义退出信号。 -
flags 为 0:
kernel_clone_args的flags字段默认初始化为 0,意味着 fork() 不设置任何CLONE_*标志位。这表示父子进程之间不共享任何资源 —— 地址空间、文件描述符表、信号处理器等全部独立复制。 -
MMU 依赖:fork() 仅在配置了
CONFIG_MMU(内存管理单元)的系统上可用。无 MMU 系统(如某些嵌入式平台)不支持 fork(),因为 COW 机制依赖硬件页表保护。 -
__ARCH_WANT_SYS_FORK:并非所有架构都提供 fork() 系统调用号。某些架构只提供 clone()/clone3(),fork() 在用户空间通过 C 库封装实现。
fork() 的极简实现揭示了一个重要的设计原则:fork() 只是 kernel_clone() 的一个特例。所有真正的创建逻辑都在 kernel_clone() 和 copy_process() 中。
10.1.2 kernel_clone() —— 进程创建的中央调度器
kernel_clone() 是所有进程创建操作的统一入口。无论是 fork()、vfork()、clone()、clone3(),还是内核线程(kernel_thread),都通过它调度。函数定义在 kernel/fork.c 第 2612-2697 行:
// kernel/fork.c, line 2612
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
int trace = 0;
pid_t nr;
第一步:参数互斥检查
// line 2630-2633
if ((clone_flags & CLONE_PIDFD) &&
(clone_flags & CLONE_PARENT_SETTID) &&
(args->pidfd == args->parent_tid))
return -EINVAL;
对于传统的 clone() 系统调用,CLONE_PIDFD 复用了 parent_tid 参数来返回 pidfd。因此 CLONE_PIDFD 和 CLONE_PARENT_SETTID 不能指向同一块用户内存。clone3() 为 pidfd 提供了独立的字段,但内核仍然在此做统一检查以保持逻辑一致性。
第二步:确定 ptrace 事件类型
// line 2641-2651
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
ptrace(进程跟踪)子系统需要知道创建的是哪种类型的子进程。三种事件的对应关系:
PTRACE_EVENT_FORK:fork() 创建的普通子进程(exit_signal == SIGCHLD,无 CLONE_VFORK)PTRACE_EVENT_VFORK:vfork() 创建的子进程(设置了 CLONE_VFORK)PTRACE_EVENT_CLONE:clone() 创建的子进程(exit_signal != SIGCHLD)
如果当前进程没有被 strace 跟踪,ptrace_event_enabled() 返回 false,trace 被设为 0 以避免不必要的开销。
第三步:调用 copy_process() 创建新进程
// line 2653-2657
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
add_latent_entropy();
if (IS_ERR(p))
return PTR_ERR(p);
copy_process() 是进程创建的真正核心(将在 10.3 节详细分析)。它完成所有资源的复制和初始化,但不会将新进程加入运行队列。第一个参数 NULL 表示由 copy_process() 内部分配 PID。add_latent_entropy() 将新进程的指针地址作为熵源加入内核随机数生成器。
如果 copy_process() 失败,它返回一个 ERR_PTR 错误指针,kernel_clone() 直接将错误码返回给用户空间。
第四步:获取子进程 PID
// line 2663-2669
trace_sched_process_fork(current, p);
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, args->parent_tid);
get_task_pid() 获取子进程的 struct pid 指针,pid_vnr() 将其转换为调用者所在 PID 命名空间中的虚拟 PID 号。这确保了在容器等嵌套命名空间场景下,父进程看到的是正确的 PID。
如果设置了 CLONE_PARENT_SETTID 标志,内核通过 put_user() 将子进程 PID 写入父进程提供的用户空间地址。glibc 的 pthread 实现利用此机制获取线程 ID。
第五步:处理 CLONE_VFORK
// line 2671-2675
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
vfork() 的语义要求父进程阻塞,直到子进程调用 exec() 或 exit()。内核使用完成量(completion)机制实现这一语义:
- 在栈上分配一个
struct completion并初始化 - 将子进程的
vfork_done指向这个完成量 - 增加子进程的引用计数,防止子进程提前退出时 task_struct 被释放
第六步:唤醒子进程
// line 2684
wake_up_new_task(p);
wake_up_new_task() 定义在 kernel/sched/core.c 第 4765 行,它将新创建的子进程放入运行队列:
// kernel/sched/core.c, line 4765
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;
int wake_flags = WF_FORK;
raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
WRITE_ONCE(p->__state, TASK_RUNNING);
p->recent_used_cpu = task_cpu(p);
__set_task_cpu(p, select_task_rq(p, task_cpu(p), &wake_flags));
rq = __task_rq_lock(p, &rf);
update_rq_clock(rq);
post_init_entity_util_avg(p);
// ... 将 p 加入运行队列
关键步骤:
- 将子进程状态设为 TASK_RUNNING
- 通过调度器选择一个合适的 CPU(负载均衡)
- 初始化调度实体的利用率平均值
- 将子进程加入目标 CPU 的运行队列
从这一刻起,子进程就是一个可调度的任务了。
第七步:等待 vfork 完成
// line 2690-2693
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
return nr;
如果设置了 CLONE_VFORK,父进程调用 wait_for_vfork_done() 阻塞等待。子进程在执行 exec() 或 exit() 时会调用 mm_release(),后者通过 complete(vfork_done) 唤醒父进程。
最终,kernel_clone() 将子进程的 PID(在调用者的命名空间中)返回给父进程。
10.1.3 "fork 返回两次"的秘密
fork() 最令人困惑的特性是它在父进程和子进程中各返回一次,且返回值不同。这个看似矛盾的行为是如何实现的?
答案在于 copy_thread() 函数。它负责设置子进程的 CPU 寄存器状态,使得子进程被调度执行时看起来像是刚从系统调用返回。关键在于设置返回值寄存器:
x86_64 架构
在 arch/x86/kernel/process.c 第 170 行的 copy_thread() 中:
// arch/x86/kernel/process.c, line 242-244
frame->bx = 0;
*childregs = *current_pt_regs();
childregs->ax = 0;
子进程的 pt_regs.ax(x86_64 的系统调用返回值寄存器)被设为 0。当子进程被调度执行时,它从 ret_from_fork 汇编代码返回用户空间,此时 rax = 0,用户程序看到的 fork() 返回值就是 0。
ARM64 架构
在 arch/arm64/kernel/process.c 第 411 行:
// arch/arm64/kernel/process.c, line 433-434
*childregs = *current_pt_regs();
childregs->regs[0] = 0;
ARM64 的函数返回值通过 x0 寄存器传递。将 regs[0] 设为 0,子进程返回用户空间时 fork() 的返回值就是 0。
RISC-V 架构
在 arch/riscv/kernel/process.c 第 240 行:
// arch/riscv/kernel/process.c, line 270-280
*childregs = *(current_pt_regs());
// ...
childregs->a0 = 0; /* Return value of fork() */
p->thread.ra = (unsigned long)ret_from_fork_user_asm;
RISC-V 的返回值寄存器是 a0,设为 0。同时将返回地址(ra)设为 ret_from_fork_user_asm,确保子进程从正确的内核入口点开始执行。
返回机制总结
整个流程可以这样理解:
-
父进程路径:fork() 系统调用陷入内核 →
kernel_clone()完成创建子进程 →kernel_clone()返回子进程的 PID → 系统调用返回用户空间 → 父进程看到 fork() 返回子进程 PID -
子进程路径:子进程被调度器选中 → 从
ret_from_fork汇编入口开始执行 → 返回用户空间 → 由于返回值寄存器被设为 0 → 子进程看到 fork() 返回 0
两条路径完全独立,父进程在系统调用上下文中返回,子进程在调度器上下文中"返回"。这就是 "fork 返回两次" 的本质。
10.1.4 fork() 的语义详解
地址空间
fork() 之后,子进程拥有父进程地址空间的独立副本。但这里的 "副本" 并不是在 fork() 时就真正复制的,而是通过写时复制(COW)延迟到实际写入时才复制。
在 copy_mm() 中(kernel/fork.c 第 1557 行):
// kernel/fork.c, line 1557
static int copy_mm(u64 clone_flags, struct task_struct *tsk)
{
// ...
oldmm = current->mm;
if (!oldmm)
return 0;
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm);
if (!mm)
return -ENOMEM;
}
由于 fork() 不设置 CLONE_VM,走 dup_mm() 分支。dup_mm() 复制 mm_struct 和页表,但将所有可写页面标记为只读。此后:
- 任何对共享页面的读取操作正常执行,不需要额外开销
- 任何对共享页面的写入操作触发缺页异常,内核此时才复制该页面
文件描述符
fork() 之后,子进程获得父进程文件描述符表的副本。这通过 copy_files() 实现(kernel/fork.c 第 1615 行):
static int copy_files(u64 clone_flags, struct task_struct *tsk,
int no_files)
{
struct files_struct *oldf, *newf;
oldf = current->files;
if (!oldf)
return 0;
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count);
return 0;
}
newf = dup_fd(oldf, NULL);
if (IS_ERR(newf))
return PTR_ERR(newf);
tsk->files = newf;
return 0;
}
由于 fork() 的 flags 为 0,不包含 CLONE_FILES,所以调用 dup_fd() 创建新的文件描述符表。关键点在于:
- 文件描述符表是独立的:子进程关闭某个 fd 不影响父进程
- 底层文件对象是共享的:父子进程的同一个 fd 指向同一个
struct file - 文件偏移量是共享的:因为偏移量存储在
struct file中,而不是 fd 表中
这意味着如果子进程写入 fd 1(stdout),父进程的 fd 1 的偏移量也会移动。这个行为是 POSIX 标准要求的。
信号处理器
fork() 复制父进程的信号处理器表。子进程继承父进程对所有信号的处理方式(忽略、默认、捕获),但挂起的信号不会被继承。子进程的挂起信号队列在 copy_signal() 中通过 init_sigpending() 初始化为空。
10.1.5 fork() 的开销分析
不使用 COW 的开销
在没有 COW 的系统中,fork() 必须复制父进程的所有物理页面。假设一个进程使用了 512 MB 内存:
- 页面大小 4 KB,需要复制 131072 个页面
- 每次页面复制涉及内核空间到内核空间的内存拷贝(4 KB)
- 还需要为每个页面分配新的物理页帧、建立新的页表映射
- 典型系统上,这种 fork() 可能需要 2-10 毫秒
使用 COW 的开销
有了 COW,fork() 只需要:
- 复制父进程的 mm_struct(约几百字节)
- 复制页表(三级或四级页表,取决于架构和地址空间使用情况)
- 将共享页面标记为只读
对于一个使用 512 MB 内存的进程: - 页表大小约为 512 MB / 4 KB * 8 bytes = 1 MB(只计算叶子页表项) - 实际需要复制的页表层级数据约几百 KB - fork() 的典型开销降至约 100-300 微秒
实际测量数据
在现代 x86_64 系统上,fork() 的典型耗时:
| 操作 | 耗时 |
|---|---|
| fork()(空闲进程,COW) | 50-100 us |
| fork()(适度内存使用,COW) | 100-300 us |
| fork()(大量内存使用,COW) | 300-1000 us |
| fork()(无 COW,512MB) | 2000-10000 us |
正是 COW 的存在,使得 fork() 在大多数场景下都足够快,也让 shell 管道(fork + exec)的模式在 Linux 上非常高效。
fork() 开销的现实影响
尽管 COW 让 fork() 变得高效,但在某些场景下仍然不够快:
-
大规模并行 fork:Redis 在进行后台保存时使用 fork()。如果 Redis 使用了 10 GB 内存,fork() 仍然需要复制约 20 MB 的页表数据,在负载高时可能造成毫秒级延迟。
-
COW 页面抖动:如果 fork() 后父子进程都大量写入内存,COW 页面会被大量复制,反而比直接复制更慢(因为每次复制都触发一次缺页异常)。
-
内存过量提交:COW 意味着内核承诺的内存总量可能远大于实际物理内存。Linux 默认允许过量提交(overcommit),但如果物理内存耗尽,OOM Killer 会强制终止进程。
这些限制正是 vfork() 和 posix_spawn() 存在的原因。vfork() 通过共享地址空间(设置 CLONE_VM)完全避免了页表复制,但要求子进程在调用 exec() 或 exit() 之前不能修改内存。posix_spawn() 则将 fork + exec 合并为一个原子操作,内核可以优化整个流程。
10.1.6 vfork() 的实现
vfork() 是 fork() 的一个变体,定义在同一文件的第 2748-2758 行:
// kernel/fork.c, line 2748
#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
#endif
与 fork() 相比,vfork() 设置了两个额外的标志:
CLONE_VM:父子进程共享同一个地址空间。copy_mm()检测到此标志后不会调用dup_mm(),而是简单地增加 mm_struct 的引用计数。CLONE_VFORK:父进程在kernel_clone()中阻塞等待子进程完成。
这两个标志的组合确保了 vfork() 的语义:子进程在父进程的地址空间中运行(因此不能修改栈或全局变量),父进程一直等待直到子进程调用 exec() 或 _exit()。由于不需要复制地址空间和页表,vfork() 的开销极低,通常在 10-30 微秒量级。
小结
fork() 的实现体现了 Linux 内核设计的优雅:一个看似复杂的系统调用,其入口实现只有寥寥几行,将所有真正的逻辑委托给 kernel_clone() 和 copy_process()。COW 机制让 fork() 在绝大多数场景下保持高效,而 "fork 返回两次" 的秘密则隐藏在 copy_thread() 对子进程返回值寄存器的巧妙设置中。理解 fork() 的完整路径,为深入理解 clone() 的灵活性和 copy_process() 的复杂性奠定了基础。
10.2 clone() —— 细粒度进程创建
如果说 fork() 是一把大锤——粗暴地将所有资源复制一遍,那么 clone() 就是一把手术刀——精确地控制父子进程之间共享哪些资源、复制哪些资源。在 Linux 内核的设计哲学中,clone() 才是真正的进程创建原语,fork() 和 vfork() 只不过是 clone() 的特化封装。
Linux 的线程模型建立在 clone() 之上。POSIX 线程(pthreads)在 Linux 中的实现本质上就是设置了特定 CLONE_* 标志组合的 clone() 调用。内核本身并不区分 "进程" 和 "线程"——所有执行实体都是 task_struct,差异仅在于它们共享了多少资源。
10.2.1 clone() 系统调用
clone() 系统调用的原型如下:
long clone(unsigned long flags, void *stack,
int *parent_tid, unsigned long tls,
int *child_tid);
与 fork() 不同,clone() 接受多个参数来精确控制新进程的创建行为。在 kernel/fork.c 第 2760-2796 行可以看到其实现:
// kernel/fork.c, line 2760
#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
unsigned long, tls,
int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#endif
{
struct kernel_clone_args args = {
.flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
.stack = newsp,
.tls = tls,
};
return kernel_clone(&args);
}
#endif
注意几个重要细节:
参数顺序的架构差异:由于历史原因,不同架构的 clone() 系统调用参数顺序不同。x86 使用标准顺序,某些架构(如 ARM 的旧配置)使用 CONFIG_CLONE_BACKWARDS,IA64 使用 CONFIG_CLONE_BACKWARDS2。这种混乱是 clone3() 被引入的原因之一。
信号与标志位的分离:clone_flags 的低 8 位(CSIGNAL,0x000000ff)是退出信号,高 24 位是 CLONE_* 标志。在 kernel_clone_args 构造中,flags 取 clone_flags 的高位(去除 CSIGNAL),exit_signal 取低位。
pidfd 复用 parent_tidptr:在旧版 clone() 中,CLONE_PIDFD 标志复用了 parent_tidptr 参数来返回 pidfd。这是一个参数复用的 hack,在 clone3() 中得到了修正。
10.2.2 clone3() 系统调用
clone3() 是 Linux 5.3 引入的新一代进程创建系统调用,使用结构体参数替代了 clone() 的位置参数。它定义在 kernel/fork.c 第 2934-2956 行:
// kernel/fork.c, line 2934
SYSCALL_DEFINE2(clone3, struct clone_args __user *, uargs, size_t, size)
{
int err;
struct kernel_clone_args kargs;
pid_t set_tid[MAX_PID_NS_LEVEL];
#ifdef __ARCH_BROKEN_SYS_CLONE3
return -ENOSYS;
#endif
kargs.set_tid = set_tid;
err = copy_clone_args_from_user(&kargs, uargs, size);
if (err)
return err;
if (!clone3_args_valid(&kargs))
return -EINVAL;
return kernel_clone(&kargs);
}
clone_args 结构体
clone3() 的用户空间参数结构体定义在 include/uapi/linux/sched.h 第 92-104 行:
// include/uapi/linux/sched.h, line 92
struct clone_args {
__aligned_u64 flags; /* 标志位 */
__aligned_u64 pidfd; /* CLONE_PIDFD 时返回 pidfd */
__aligned_u64 child_tid; /* CLONE_CHILD_SETTID 时写入子进程 TID */
__aligned_u64 parent_tid; /* CLONE_PARENT_SETTID 时写入父进程 */
__aligned_u64 exit_signal; /* 子进程退出信号 */
__aligned_u64 stack; /* 子进程栈位置 */
__aligned_u64 stack_size; /* 子进程栈大小 */
__aligned_u64 tls; /* CLONE_SETTLS 时的 TLS 描述符 */
__aligned_u64 set_tid; /* 指定 PID 的数组 */
__aligned_u64 set_tid_size; /* set_tid 数组大小 */
__aligned_u64 cgroup; /* CLONE_INTO_CGROUP 时的 cgroup fd */
};
clone3() 相对于 clone() 的优势:
-
可扩展性:结构体按大小版本化。新增字段只需追加到结构体末尾,旧程序传入较小的 size 仍然兼容。当前定义了三个版本:
c #define CLONE_ARGS_SIZE_VER0 64 /* 原始版本(到 tls 为止) */ #define CLONE_ARGS_SIZE_VER1 80 /* 增加set_tid/set_tid_size */ #define CLONE_ARGS_SIZE_VER2 88 /* 增加 cgroup */ -
独立的 pidfd 字段:不再复用 parent_tidptr 参数。
-
明确的栈大小:clone() 只接受栈起始地址,栈增长方向由架构隐式决定。clone3() 显式传入 stack_size,内核自动调整栈指针方向。
-
PID 预分配:通过
set_tid数组,可以在嵌套的 PID 命名空间中为子进程指定 PID。 -
cgroup 集成:
CLONE_INTO_CGROUP标志允许在创建进程时直接将其放入指定 cgroup,避免了创建后再迁移的竞态条件。
参数验证
clone3() 的参数验证分为两层。copy_clone_args_from_user()(第 2798 行)负责从用户空间拷贝并转换参数:
// kernel/fork.c, line 2798
static noinline int copy_clone_args_from_user(struct kernel_clone_args *kargs,
struct clone_args __user *uargs,
size_t usize)
它调用 copy_struct_from_user() 实现向后兼容——如果用户空间传入的结构体比内核的短,多余字段清零;如果更长,检查多余字段是否全零。
clone3_args_valid()(第 2895 行)执行进一步的合法性检查:
// kernel/fork.c, line 2895
static bool clone3_args_valid(struct kernel_clone_args *kargs)
{
/* 拒绝未知标志位 */
if (kargs->flags &
~(CLONE_LEGACY_FLAGS | CLONE_CLEAR_SIGHAND | CLONE_INTO_CGROUP))
return false;
/* clone3 中 CLONE_DETACHED 和 CSIGNAL 被重新利用,不允许设置 */
if (kargs->flags & (CLONE_DETACHED | (CSIGNAL & (~CLONE_NEWTIME))))
return false;
/* CLONE_SIGHAND 和 CLONE_CLEAR_SIGHAND 互斥 */
if ((kargs->flags & (CLONE_SIGHAND | CLONE_CLEAR_SIGHAND)) ==
(CLONE_SIGHAND | CLONE_CLEAR_SIGHAND))
return false;
/* CLONE_THREAD/CLONE_PARENT 时 exit_signal 必须为 0 */
if ((kargs->flags & (CLONE_THREAD | CLONE_PARENT)) &&
kargs->exit_signal)
return false;
if (!clone3_stack_valid(kargs))
return false;
return true;
}
特别注意 CLONE_DETACHED 在 clone3() 中被明确禁止。在旧版 clone() 中,这个标志没有实际效果(被忽略),但 clone3() 将其保留为错误值,为将来的功能重用做准备。
10.2.3 CLONE_* 标志位详解
CLONE_* 标志位定义在 include/uapi/linux/sched.h 第 10-38 行,是 Linux 进程创建机制的核心控制参数。每个标志位控制一种特定资源的共享或复制行为。
资源共享类标志
| 标志 | 值 | 功能 | 对应的 copy_* 函数 |
|---|---|---|---|
| CLONE_VM | 0x00000100 | 共享地址空间 | copy_mm() |
| CLONE_FS | 0x00000200 | 共享文件系统信息(根目录、当前目录、umask) | copy_fs() |
| CLONE_FILES | 0x00000400 | 共享文件描述符表 | copy_files() |
| CLONE_SIGHAND | 0x00000800 | 共享信号处理器表 | copy_sighand() |
| CLONE_SYSVSEM | 0x00040000 | 共享 System V 信号量撤销值 | copy_semundo() |
| CLONE_IO | 0x80000000 | 共享 I/O 调度上下文 | copy_io() |
当设置了某个共享标志时,对应的 copy_* 函数不会复制资源,而是增加引用计数并让父子进程指向同一个数据结构。例如 copy_files():
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count); // 引用计数 +1,不复制
return 0;
}
newf = dup_fd(oldf, NULL); // 不共享时才复制
进程关系类标志
| 标志 | 值 | 功能 |
|---|---|---|
| CLONE_THREAD | 0x00010000 | 将子进程放入同一线程组 |
| CLONE_PARENT | 0x00008000 | 子进程的父进程设为调用者的父进程 |
| CLONE_VFORK | 0x00004000 | 父进程阻塞直到子进程 exec/exit |
CLONE_THREAD 是线程实现的核心标志。设置后:
- 子进程的 group_leader 指向当前线程组的主线程
- 子进程的 tgid(线程组 ID)等于主线程的 PID
- 子进程不创建新的 signal_struct(copy_signal() 直接返回)
- 子进程的 exit_signal 被设为 -1(退出时不发送信号)
CLONE_PARENT 让子进程成为调用者的兄弟而非子进程。子进程的 real_parent 被设为调用者的 real_parent。这个标志在某些守护进程的创建场景中使用。
命名空间标志
| 标志 | 值 | 功能 |
|---|---|---|
| CLONE_NEWCGROUP | 0x02000000 | 新 cgroup 命名空间 |
| CLONE_NEWUTS | 0x04000000 | 新 UTS 命名空间(主机名等) |
| CLONE_NEWIPC | 0x08000000 | 新 IPC 命名空间 |
| CLONE_NEWUSER | 0x10000000 | 新用户命名空间 |
| CLONE_NEWPID | 0x20000000 | 新 PID 命名空间 |
| CLONE_NEWNET | 0x40000000 | 新网络命名空间 |
| CLONE_NEWNS | 0x00020000 | 新挂载命名空间 |
| CLONE_NEWTIME | 0x00000080 | 新时间命名空间 |
命名空间标志在 copy_namespaces() 中处理。每个命名空间标志都会为子进程创建一个新的命名空间实例。Docker 等容器技术大量使用这些标志来隔离进程的执行环境。
TID 操作类标志
| 标志 | 值 | 功能 |
|---|---|---|
| CLONE_PARENT_SETTID | 0x00100000 | 将子进程 TID 写入 parent_tid 指向的地址 |
| CLONE_CHILD_SETTID | 0x01000000 | 将子进程 TID 写入 child_tid 指向的地址(子进程空间) |
| CLONE_CHILD_CLEARTID | 0x00200000 | 子进程退出时清除 child_tid 指向的地址并唤醒 futex |
| CLONE_SETTLS | 0x00080000 | 为子进程设置 TLS(线程本地存储) |
这些标志主要用于 pthread 实现。CLONE_CHILD_CLEARTID 配合 futex 机制实现了 pthread_join() 的功能——当线程退出时,清除用户空间的 TID 并唤醒等待该 futex 的线程。
其他标志
| 标志 | 值 | 功能 |
|---|---|---|
| CLONE_PIDFD | 0x00001000 | 创建 pidfd(进程文件描述符) |
| CLONE_PTRACE | 0x00002000 | 允许 ptrace 继续跟踪子进程 |
| CLONE_UNTRACED | 0x00800000 | 禁止 ptrace 跟踪子进程 |
| CLONE_CLEAR_SIGHAND | 0x100000000 | 清除所有信号处理器为 SIG_DFL(仅 clone3) |
| CLONE_INTO_CGROUP | 0x200000000 | 将子进程放入指定 cgroup(仅 clone3) |
CLONE_PIDFD 是 Linux 5.3 引入的重要特性。pidfd 是一个指向进程的文件描述符,可以用来安全地发送信号或等待进程退出,避免了 PID 回收竞态问题(PID 被回收后新进程复用同一个 PID)。
10.2.4 标志位依赖规则
CLONE_* 标志位并非可以自由组合,它们之间存在严格的依赖关系。copy_process() 在创建新进程之前会检查这些规则(kernel/fork.c 第 1980-2032 行):
规则一:CLONE_NEWNS 与 CLONE_FS 互斥
// line 1984
if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
return ERR_PTR(-EINVAL);
创建新的挂载命名空间(CLONE_NEWNS)意味着子进程拥有独立的挂载点视图。如果同时共享文件系统信息(CLONE_FS),子进程的 chroot 或 chdir 会影响父进程,导致命名空间隔离被打破。
类似的,CLONE_NEWUSER 也与 CLONE_FS 互斥(第 1987 行)。
规则二:CLONE_THREAD 要求 CLONE_SIGHAND
// line 1994
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);
同一线程组中的线程必须共享信号处理器。这是因为 POSIX 规定同一进程中的所有线程共享信号处置(signal disposition)。如果线程有不同的信号处理器表,信号投递将变得不一致。
规则三:CLONE_SIGHAND 要求 CLONE_VM
// line 2002
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);
共享信号处理器要求共享地址空间。信号处理器是用户空间函数指针,如果地址空间不同,父进程的信号处理器函数指针在子进程中可能指向不同的代码。
规则四:CLONE_PARENT 与 SIGNAL_UNKILLABLE 互斥
// line 2011
if ((clone_flags & CLONE_PARENT) &&
current->signal->flags & SIGNAL_UNKILLABLE)
return ERR_PTR(-EINVAL);
全局 init 进程(PID 1)和容器 init 进程带有 SIGNAL_UNKILLABLE 标志。如果允许 init 创建兄弟进程(CLONE_PARENT),这些兄弟进程在退出时不会被 init 的父进程(swapper)回收,会变成僵尸进程。
规则五:CLONE_THREAD 与命名空间标志互斥
// line 2019
if (clone_flags & CLONE_THREAD) {
if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||
(task_active_pid_ns(current) != nsp->pid_ns_for_children))
return ERR_PTR(-EINVAL);
}
同一线程组的线程必须属于同一个用户命名空间和 PID 命名空间。新线程不能突然拥有不同的 PID 或用户身份。
依赖关系图
CLONE_THREAD
└── 要求 CLONE_SIGHAND
└── 要求 CLONE_VM
CLONE_NEWNS ──互斥── CLONE_FS
CLONE_NEWUSER ──互斥── CLONE_FS
CLONE_THREAD ──互斥── CLONE_NEWUSER, CLONE_NEWPID
CLONE_PARENT ──互斥── SIGNAL_UNKILLABLE(init 进程)
CLONE_PIDFD ──互斥── CLONE_DETACHED
CLONE_PIDFD ──互斥── CLONE_PARENT_SETTID(指向同一地址时)
10.2.5 pthread_create() 如何使用 clone()
POSIX 线程(pthreads)在 Linux 上的实现(glibc 的 nptl)通过 clone() 创建新线程。glibc 调用 clone() 时使用的典型标志组合如下:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
CLONE_THREAD | CLONE_SYSVSEM |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID |
CLONE_SETTLS,
stack, parent_tidptr, tls, child_tidptr);
逐个分析这些标志:
- CLONE_VM:线程共享地址空间,这是最基本的要求。不复制页表,父子(实际上是兄弟)线程直接读写同一块内存。
- CLONE_FS:线程共享根目录、当前工作目录和 umask。一个线程调用 chdir() 会影响同进程的所有线程。
- CLONE_FILES:线程共享文件描述符表。一个线程打开的文件可以被同进程的其他线程通过同一个 fd 访问。
- CLONE_SIGHAND:线程共享信号处理器表。一个线程调用 signal() 或 sigaction() 修改信号处理方式会影响所有线程。
- CLONE_THREAD:新线程被放入调用者的线程组。这意味着
getpid()返回线程组 ID(即主线程的 PID),而gettid()返回实际的内核 PID。 - CLONE_SYSVSEM:线程共享 System V 信号量的撤销值。
- CLONE_PARENT_SETTID:将新线程的 TID 写入
parent_tidptr。glibc 用这个值作为pthread_t。 - CLONE_CHILD_CLEARTID:线程退出时清除
child_tidptr并唤醒 futex。glibc 的pthread_join()等待这个 futex。 - CLONE_SETTLS:设置线程本地存储(TLS)指针。每个线程有自己的 TLS 段,用于存储
errno、线程特定数据等。
注意 没有设置 的标志:
- 没有 CLONE_VFORK:线程创建后父线程不阻塞
- 没有任何 CLONE_NEW* 命名空间标志:线程与创建者共享所有命名空间
这就是为什么 Linux 线程被称为 "轻量级进程" —— 它们本质上是共享了大部分资源的进程。内核看到的每一个执行上下文都是一个 task_struct,线程和进程的区别仅在于它们共享了哪些资源。
10.2.6 kernel_clone_args 结构体
kernel_clone_args 是内核内部用于传递进程创建参数的结构体,定义在 include/linux/sched/task.h 第 23-47 行:
// include/linux/sched/task.h, line 23
struct kernel_clone_args {
u64 flags; /* CLONE_* 标志位 */
int __user *pidfd; /* pidfd 输出地址 */
int __user *child_tid; /* 子进程 TID 地址 */
int __user *parent_tid; /* 父进程 TID 地址 */
const char *name; /* 内核线程名称 */
int exit_signal; /* 退出信号 */
u32 kthread:1; /* 是否为内核线程 */
u32 io_thread:1; /* 是否为 I/O 线程 */
u32 user_worker:1; /* 是否为用户工作线程 */
u32 no_files:1; /* 是否不继承文件描述符 */
unsigned long stack; /* 用户栈地址 */
unsigned long stack_size; /* 用户栈大小 */
unsigned long tls; /* TLS 指针 */
pid_t *set_tid; /* 指定 PID 的数组 */
size_t set_tid_size; /* set_tid 数组长度 */
int cgroup; /* 目标 cgroup fd */
int idle; /* idle 线程标记 */
int (*fn)(void *); /* 内核线程入口函数 */
void *fn_arg; /* 内核线程入口参数 */
struct cgroup *cgrp; /* 内核 cgroup 指针 */
struct css_set *cset; /* cgroup css_set */
unsigned int kill_seq; /* 终止序列号 */
};
这个结构体比用户空间的 clone_args 更丰富,因为它包含了内核内部使用的字段:
fn/fn_arg:内核线程(kernel_thread)的入口函数和参数。当设置了这些字段时,新进程直接执行内核函数而非返回用户空间。kthread:标记为内核线程。内核线程没有用户空间地址空间,运行在内核态。io_thread:标记为 I/O 线程(用于 io_uring 等异步 I/O 机制)。cgrp/cset:内核内部使用的 cgroup 信息,由cgroup_can_fork()填充。
不同系统调用到 kernel_clone_args 的映射:
| 系统调用 | flags | exit_signal | 特殊字段 |
|---|---|---|---|
| fork() | 0 | SIGCHLD | 无 |
| vfork() | CLONE_VM | CLONE_VFORK | SIGCHLD | 无 |
| clone() | 用户指定 | 低 8 位 | stack, tls, parent_tid, child_tid |
| clone3() | 用户指定 | 用户指定 | 所有字段 |
| kernel_thread() | CLONE_VM | CLONE_UNTRACED | 低 8 位 | fn, fn_arg, kthread=1 |
小结
clone() 是 Linux 进程创建的万能原语。通过组合不同的 CLONE_* 标志位,它可以从创建完全独立的进程(fork 语义)到完全共享的线程(pthread 语义),以及介于两者之间的各种混合形态。clone3() 作为其现代继承者,通过结构体化的参数接口解决了 clone() 的参数歧义问题,并增加了 PID 预分配、cgroup 集成等新特性。
标志位之间的依赖规则确保了资源共享的一致性——共享信号处理器必须共享地址空间,同一线程组必须共享信号处理器。这些规则反映了操作系统设计中的基本原理:共享的资源不能违反隔离的语义。
理解了 clone() 的机制和标志位系统,我们就可以深入 copy_process() 的内部,看看这些标志位是如何被逐个处理的。
10.3 copy_process() 内部实现
copy_process() 是 Linux 进程创建的心脏。所有通过 fork()、vfork()、clone()、clone3() 创建新进程的请求,最终都汇聚到这个函数。它的职责是:以当前进程(current)为模板,创建一个全新的 task_struct,根据 CLONE_* 标志位决定复制或共享哪些资源,完成 PID 分配和进程关系建立,然后将新进程返回给调用者。
copy_process() 不唤醒新进程。这个设计是有意为之的——将 "创建" 和 "调度" 分离,使得调用者(kernel_clone())有机会在子进程运行之前完成一些额外操作(如 ptrace 通知、vfork 设置等)。
函数定义在 kernel/fork.c 第 1967-2476 行,是一个超过 500 行的巨型函数。虽然函数很长,但它的逻辑是线性的:先验证参数,再依次复制各类资源,最后建立进程关系。让我们逐段深入分析。
10.3.1 函数签名与局部变量
// kernel/fork.c, line 1967
__latent_entropy struct task_struct *copy_process(
struct pid *pid,
int trace,
int node,
struct kernel_clone_args *args)
{
int pidfd = -1, retval;
struct task_struct *p;
struct multiprocess_signals delayed;
struct file *pidfile = NULL;
const u64 clone_flags = args->flags;
struct nsproxy *nsp = current->nsproxy;
参数说明:
- pid:预先分配的 PID。通常为 NULL(由 copy_process 内部调用 alloc_pid() 分配)。仅在创建 idle 进程(fork_idle)时传入预先分配的 init_struct_pid。
- trace:ptrace 事件类型(PTRACE_EVENT_FORK/VFORK/CLONE),或 0 表示不需要跟踪。
- node:NUMA 节点号,指定新进程的 task_struct 和内核栈分配在哪个内存节点。通常为 NUMA_NO_NODE(由内核自动选择)。
- args:完整的创建参数,包含 CLONE 标志、栈地址、TLS 等。
__latent_entropy 注解是一个 GCC 插件特性,用于在编译时注入随机性,增加内核对抗某些安全攻击的能力。
10.3.2 参数合法性验证(第 1980-2032 行)
copy_process() 的第一步是检查 CLONE_* 标志位的组合是否合法。这些检查是内核安全的第一道防线。
CLONE_NEWNS 与 CLONE_FS 互斥
// line 1984-1985
if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
return ERR_PTR(-EINVAL);
新挂载命名空间(CLONE_NEWNS)提供独立的文件系统挂载视图。CLONE_FS 让父子进程共享根目录、当前目录和 umask。两者同时设置会导致语义矛盾:子进程有独立的挂载点视图,但 chdir/chroot 又会相互影响。
CLONE_THREAD 要求 CLONE_SIGHAND
// line 1994-1995
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);
POSIX 标准要求同一进程的所有线程共享信号处置。如果允许同一线程组中的线程有不同的信号处理器表,信号投递将无法确定应该使用哪个处理器。
CLONE_SIGHAND 要求 CLONE_VM
// line 2002-2003
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);
信号处理器是用户空间函数指针。如果地址空间不共享(没有 CLONE_VM),父进程中的函数指针在子进程中可能指向不同的代码或无效地址。
init 进程不能创建兄弟
// line 2011-2013
if ((clone_flags & CLONE_PARENT) &&
current->signal->flags & SIGNAL_UNKILLABLE)
return ERR_PTR(-EINVAL);
CLONE_PARENT 让子进程成为调用者的兄弟。全局 init(PID 1)和容器 init 带有 SIGNAL_UNKILLABLE 标志。如果 init 创建兄弟进程,这些进程退出时不会被 init 的父进程(PID 0,swapper)回收,变成不可回收的僵尸进程。
CLONE_THREAD 与新命名空间互斥
// line 2019-2022
if (clone_flags & CLONE_THREAD) {
if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||
(task_active_pid_ns(current) != nsp->pid_ns_for_children))
return ERR_PTR(-EINVAL);
}
线程组的所有线程必须属于同一个 PID 命名空间和用户命名空间。同进程的线程不能突然获得不同的用户身份或 PID 视图。
CLONE_PIDFD 与 CLONE_DETACHED 互斥
// line 2025-2031
if (clone_flags & CLONE_PIDFD) {
if (clone_flags & CLONE_DETACHED)
return ERR_PTR(-EINVAL);
}
CLONE_DETACHED 在旧版 clone() 中被忽略,但其值被保留。内核希望未来能重用这个标志位来扩展 CLONE_PIDFD 的功能,因此阻止两者同时出现。
10.3.3 信号处理与阻塞(第 2040-2050 行)
在开始创建子进程之前,copy_process() 需要处理信号同步问题:
// line 2040-2050
sigemptyset(&delayed.signal);
INIT_HLIST_NODE(&delayed.node);
spin_lock_irq(¤t->sighand->siglock);
if (!(clone_flags & CLONE_THREAD))
hlist_add_head(&delayed.node, ¤t->signal->multiprocess);
recalc_sigpending();
spin_unlock_irq(¤t->sighand->siglock);
retval = -ERESTARTNOINTR;
if (task_sigpending(current))
goto fork_out;
这段代码解决了一个微妙的竞态条件:如果在 fork() 执行期间,一个信号(如 SIGTERM)被发送给当前进程组中的所有进程,这个信号应该在 fork() 之前还是之后投递给子进程?
内核的选择是:延迟处理多进程信号。具体机制:
- 初始化一个
multiprocess_signals结构体,挂入当前进程的multiprocess链表 - 当信号处理代码(如
kill_something_info())发送组信号时,会遍历这个链表,将信号记录在delayed.signal中 - 子进程创建完成后,这些延迟的信号会被转移到子进程的共享挂起信号队列中
此外,如果在 fork() 开始时当前进程已有挂起的信号,fork() 会被中止(返回 -ERESTARTNOINTR),让信号先被处理。这确保了 fork() 不会在信号处理程序中间创建子进程,避免状态不一致。
10.3.4 复制 task_struct(第 2053 行)
参数验证通过后,下一步是分配新的 task_struct:
// line 2053-2055
retval = -ENOMEM;
p = dup_task_struct(current, node);
if (!p)
goto fork_out;
dup_task_struct() 是进程创建的第一个实质性步骤,定义在 kernel/fork.c 第 910 行:
// kernel/fork.c, line 910
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
struct task_struct *tsk;
int err;
if (node == NUMA_NO_NODE)
node = tsk_fork_get_node(orig);
tsk = alloc_task_struct_node(node);
if (!tsk)
return NULL;
err = arch_dup_task_struct(tsk, orig);
if (err)
goto free_tsk;
err = alloc_thread_stack_node(tsk, node);
if (err)
goto free_tsk;
它的工作步骤:
-
分配 task_struct:
alloc_task_struct_node()从 slab 分配器分配一个新的 task_struct。在大多数架构上,这是一个 slub cache 对象。 -
复制 task_struct 内容:
arch_dup_task_struct()将父进程的整个 task_struct 原样拷贝到新分配的结构体中。这是一个纯内存拷贝操作(memcpy),意味着新进程初始状态下拥有与父进程完全相同的所有字段值。 -
分配内核栈:
alloc_thread_stack_node()为新进程分配独立的内核栈。如果启用了CONFIG_VMAP_STACK(默认启用),内核栈通过 vmalloc 分配,位于连续的虚拟地址空间中(物理页面可以不连续)。
// line 928-951(续)
refcount_set(&tsk->stack_refcount, 1);
account_kernel_stack(tsk, 1);
err = scs_prepare(tsk, node); // 影子调用栈(Shadow Call Stack)
if (err)
goto free_stack;
tsk->seccomp.filter = NULL; // seccomp 过滤器稍后在 sighand lock 下设置
setup_thread_stack(tsk, orig);
clear_user_return_notifier(tsk);
clear_tsk_need_resched(tsk);
set_task_stack_end_magic(tsk);
-
初始化 thread_info:
setup_thread_stack()设置 thread_info(嵌入在 task_struct 中或位于栈底)。 -
设置栈结束标记:
set_task_stack_end_magic()在内核栈底部写入一个魔数(0x57AC6E9D),用于检测栈溢出。 -
初始化引用计数:
refcount_set(&tsk->rcu_users, 2); // 用户空间可见状态 + RCU
refcount_set(&tsk->usage, 1); // 调度器引用
回到 copy_process(),dup_task_struct() 之后还有一些标志位的初始化:
// line 2056-2077
p->flags &= ~PF_KTHREAD;
if (args->kthread)
p->flags |= PF_KTHREAD;
if (args->user_worker) {
p->flags |= PF_USER_WORKER;
siginitsetinv(&p->blocked, sigmask(SIGKILL)|sigmask(SIGSTOP));
}
if (args->io_thread)
p->flags |= PF_IO_WORKER;
if (args->name)
strscpy_pad(p->comm, args->name, sizeof(p->comm));
p->set_child_tid = (clone_flags & CLONE_CHILD_SETTID) ? args->child_tid : NULL;
p->clear_child_tid = (clone_flags & CLONE_CHILD_CLEARTID) ? args->child_tid : NULL;
由于 dup_task_struct() 是完整拷贝,子进程继承了父进程的所有标志位(包括 PF_KTHREAD)。这里首先清除这些标志,然后根据参数重新设置。
10.3.5 凭证与资源限制检查(第 2087-2106 行)
复制凭证
// line 2087-2089
retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;
copy_creds() 复制(或共享)父进程的安全凭证,包括 uid/gid、能力集(capabilities)、安全上下文(SELinux 标签)等。对于设置了 CLONE_THREAD 的线程,凭证通常是共享的;对于 fork() 创建的子进程,凭证被独立复制。
RLIMIT_NPROC 检查
// line 2092-2096
if (is_rlimit_overlimit(task_ucounts(p), UCOUNT_RLIMIT_NPROC, rlimit(RLIMIT_NPROC))) {
if (p->real_cred->user != INIT_USER &&
!capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
goto bad_fork_cleanup_count;
}
RLIMIT_NPROC 限制用户可以拥有的进程数量。这是一个防御 fork 炸弹(fork bomb)的机制。注意以下例外:
- INIT_USER(root 用户,UID 0)不受此限制
- 拥有 CAP_SYS_RESOURCE 或 CAP_SYS_ADMIN 能力的进程可以绕过此限制
is_rlimit_overlimit() 通过 ucount 机制统计当前用户的进程总数,然后与 RLIMIT_NPROC 比较。
最大线程数检查
// line 2105-2106
if (data_race(nr_threads >= max_threads))
goto bad_fork_cleanup_count;
max_threads 在系统启动时根据可用内存计算得出,通常为总内存 / (2 * 内核栈大小)。这个全局限制防止内核资源被耗尽。
10.3.6 字段初始化(第 2108-2193 行)
通过资源限制检查后,copy_process() 对新 task_struct 的各个字段进行初始化:
// line 2108-2111
delayacct_tsk_init(p);
p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER | PF_IDLE | PF_NO_SETAFFINITY);
p->flags |= PF_FORKNOEXEC;
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
- PF_FORKNOEXEC:标记进程已 fork 但尚未 exec。在 exec() 时此标志被清除。
- children/sibling:初始化子进程链表和兄弟链表。
// line 2117-2119
init_sigpending(&p->pending);
p->utime = p->stime = p->gtime = 0;
子进程的挂起信号队列被清空,CPU 时间统计归零。
后续还有 io_uring 初始化(第 2131 行)、POSIX CPU 定时器初始化(第 2148 行)等大量子系统的初始化代码,这里不再逐一列出。
10.3.7 资源复制序列(第 2196-2237 行)
这是 copy_process() 的核心——依次调用各个 copy_ 函数复制父进程的资源。每个函数根据 CLONE_ 标志决定是共享(增加引用计数)还是复制(创建新实例)。
sched_fork() —— 调度器初始化
// line 2196-2198
retval = sched_fork(clone_flags, p);
if (retval)
goto bad_fork_cleanup_policy;
sched_fork() 是调度器子系统的初始化入口(kernel/sched/core.c),它完成以下工作:
- 将新进程的调度策略设置为 SCHED_NORMAL(除非设置了 SCHED_RESET_ON_FORK)
- 初始化调度实体(sched_entity)的 vruntime、负载等字段
- 将新进程分配到一个 CPU(通常继承父进程的 CPU,但考虑负载均衡)
- 设置优先级为父进程的 normal_prio
- 将进程状态设为 TASK_NEW(尚不可运行)
perf_event_init_task() —— 性能事件初始化
// line 2200-2202
retval = perf_event_init_task(p, clone_flags);
if (retval)
goto bad_fork_sched_cancel_fork;
如果父进程有活跃的性能监控事件(perf events),子进程需要继承这些事件。设置了 CLONE_THREAD 时共享事件上下文,否则创建独立的上下文。
audit_alloc() —— 审计上下文
// line 2203-2205
retval = audit_alloc(p);
if (retval)
goto bad_fork_cleanup_perf;
为子进程分配审计(audit)上下文,用于记录安全审计信息。
copy_semundo() —— System V 信号量
// line 2211-2213
retval = copy_semundo(clone_flags, p);
if (retval)
goto bad_fork_cleanup_security;
System V 信号量的撤销值(semadj)记录了进程对信号量所做的修改,用于进程异常退出时自动恢复。CLONE_SYSVSEM 标志控制是否共享这些撤销值。
copy_files() —— 文件描述符表
// line 2214-2216
retval = copy_files(clone_flags, p, args->no_files);
if (retval)
goto bad_fork_cleanup_semundo;
copy_files() 的逻辑在前面的章节中已经分析过。对于 fork(),它调用 dup_fd() 创建新的文件描述符表,但底层 struct file 对象是共享的(引用计数增加)。
copy_fs() —— 文件系统信息
// line 2217-2219
retval = copy_fs(clone_flags, p);
if (retval)
goto bad_fork_cleanup_files;
copy_fs() 处理 fs_struct(包含根目录、当前目录、umask)。fork() 时创建独立的 fs_struct 副本。特别的是,如果父进程正在执行 exec(fs->in_exec 为真),copy_fs() 返回 -EAGAIN,防止在 exec 期间 fork 导致状态不一致。
copy_sighand() —— 信号处理器表
// line 2220-2222
retval = copy_sighand(clone_flags, p);
if (retval)
goto bad_fork_cleanup_fs;
copy_sighand() 的实现(第 1645 行)清晰展示了 "共享 vs 复制" 的模式:
// line 1645-1668
static int copy_sighand(u64 clone_flags, struct task_struct *tsk)
{
struct sighand_struct *sig;
if (clone_flags & CLONE_SIGHAND) {
refcount_inc(¤t->sighand->count); // 共享:引用计数 +1
return 0;
}
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL); // 复制:分配新实例
RCU_INIT_POINTER(tsk->sighand, sig);
if (!sig)
return -ENOMEM;
refcount_set(&sig->count, 1);
spin_lock_irq(¤t->sighand->siglock);
memcpy(sig->action, current->sighand->action, sizeof(sig->action));
spin_unlock_irq(¤t->sighand->siglock);
if (clone_flags & CLONE_CLEAR_SIGHAND)
flush_signal_handlers(tsk, 0);
return 0;
}
对于 fork()(没有 CLONE_SIGHAND),分配新的 sighand_struct 并复制信号处理器数组。clone3() 的 CLONE_CLEAR_SIGHAND 标志可以额外将所有非 SIG_IGN 的处理器重置为 SIG_DFL。
copy_signal() —— 信号统计结构
// line 2223-2225
retval = copy_signal(clone_flags, p);
if (retval)
goto bad_fork_cleanup_sighand;
copy_signal()(第 1694 行)创建 signal_struct。这个结构体用于线程组级别的信号管理和统计信息。对于 CLONE_THREAD,直接返回(线程共享进程的 signal_struct)。对于 fork(),分配新的 signal_struct 并初始化。
signal_struct 包含的关键信息: - 线程组中线程的数量(nr_threads) - 共享挂起信号队列 - 子进程退出等待队列(wait_chldexit) - 资源限制(rlim)——从父进程的 group_leader 复制 - POSIX 定时器列表
copy_mm() —— 地址空间
// line 2226-2228
retval = copy_mm(clone_flags, p);
if (retval)
goto bad_fork_cleanup_signal;
copy_mm()(第 1557 行)是 fork() 中开销最大的操作。对于 fork()(没有 CLONE_VM),它调用 dup_mm() 完成以下工作:
- 分配新的 mm_struct
- 复制父进程的 VMA(虚拟内存区域)链表
- 复制页表(pgd -> pud -> pmd -> pte)
- 将所有可写页面的页表项标记为只读(COW 标记)
- 增加共享文件的引用计数
对于设置了 CLONE_VM 的线程,只是简单地增加 mm_struct 的引用计数:
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm);
}
copy_namespaces() —— 命名空间
// line 2229-2231
retval = copy_namespaces(clone_flags, p);
if (retval)
goto bad_fork_cleanup_mm;
copy_namespaces() 处理所有命名空间的复制或共享。对于每个 CLONE_NEW* 标志,创建对应类型的新命名空间;没有对应标志的命名空间则与父进程共享。
例如,如果设置了 CLONE_NEWNET,子进程获得全新的网络命名空间(独立的网络接口、路由表、iptables 规则等);如果没有设置,父子进程共享同一个网络命名空间。
copy_io() —— I/O 调度上下文
// line 2232-2234
retval = copy_io(clone_flags, p);
if (retval)
goto bad_fork_cleanup_namespaces;
copy_io() 处理 I/O 调度器上下文。CLONE_IO 标志控制是否共享 I/O 上下文。大多数 fork() 不设置此标志,子进程获得独立的 I/O 调度上下文。
copy_thread() —— CPU 寄存器
// line 2235-2237
retval = copy_thread(p, args);
if (retval)
goto bad_fork_cleanup_io;
copy_thread() 是架构相关的函数,负责设置子进程的 CPU 寄存器状态。这是实现 "fork 返回两次" 的关键。以 x86_64 为例(arch/x86/kernel/process.c 第 170 行):
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
unsigned long sp = args->stack;
struct pt_regs *childregs;
childregs = task_pt_regs(p);
// ...
if (unlikely(p->flags & PF_KTHREAD)) {
// 内核线程:清空寄存器,设置入口函数
memset(childregs, 0, sizeof(struct pt_regs));
kthread_frame_init(frame, args->fn, args->fn_arg);
return 0;
}
// 用户进程:复制父进程的寄存器
frame->bx = 0;
*childregs = *current_pt_regs();
childregs->ax = 0; // 返回值 = 0(子进程看到 fork 返回 0)
if (sp)
childregs->sp = sp; // 如果指定了新栈
if (clone_flags & CLONE_SETTLS)
ret = set_new_tls(p, tls);
return ret;
}
关键操作: 1. 对于内核线程(PF_KTHREAD):寄存器清零,设置入口函数 2. 对于用户进程:完整复制父进程的 pt_regs,然后将返回值寄存器设为 0
frame->ret_addr = (unsigned long)ret_from_fork_asm 设置了子进程被调度执行时的入口点。当调度器选择子进程运行时,它从 ret_from_fork 汇编代码开始执行,最终返回用户空间。
10.3.8 PID 分配(第 2242-2248 行)
所有资源复制完成后,进入 PID 分配阶段:
// line 2242-2249
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid,
args->set_tid_size);
if (IS_ERR(pid)) {
retval = PTR_ERR(pid);
goto bad_fork_cleanup_thread;
}
}
alloc_pid() 在子进程的 PID 命名空间及其所有祖先命名空间中分配 PID。这意味着同一个进程在不同层级的命名空间中有不同的 PID。例如:
- 在最内层(容器内)PID 可能是 100
- 在中间层 PID 可能是 3456
- 在最外层(宿主机)PID 可能是 12345
clone3() 的 set_tid 参数允许指定某些命名空间中的 PID,前提是该 PID 尚未被占用。
pidfd 准备
// line 2256-2271
if (clone_flags & CLONE_PIDFD) {
int flags = (clone_flags & CLONE_THREAD) ? PIDFD_THREAD : 0;
retval = pidfd_prepare(pid, flags | PIDFD_STALE, &pidfile);
if (retval < 0)
goto bad_fork_free_pid;
pidfd = retval;
retval = put_user(pidfd, args->pidfd);
if (retval)
goto bad_fork_put_pidfd;
}
如果设置了 CLONE_PIDFD,内核在 fork 期间就创建 pidfd 文件描述符。注意 PIDFD_STALE 标志——此时 pid 还没有与任何 task_struct 关联,pidfd 被标记为 "陈旧"。在子进程被发布后,pidfd 会变为可用状态。
10.3.9 进程关系建立(第 2296-2454 行)
设置 PID 和线程组
// line 2296-2303
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->group_leader = current->group_leader;
p->tgid = current->tgid;
} else {
p->group_leader = p;
p->tgid = p->pid;
}
对于线程(CLONE_THREAD),group_leader 指向主线程,tgid 等于主线程的 PID。对于独立进程,group_leader 指向自身。
cgroup 检查
// line 2326-2328
retval = cgroup_can_fork(p, args);
if (retval)
goto bad_fork_put_pidfd;
cgroup 子系统需要在子进程可见之前检查是否允许创建。如果父进程在某个 cgroup 中达到了进程数限制,cgroup_can_fork() 会返回错误。
记录启动时间
// line 2362-2363
p->start_time = ktime_get_ns();
p->start_boottime = ktime_get_boottime_ns();
在获取 tasklist_lock 之前记录启动时间。这确保了进程的 start_time 不会被用户空间通过延迟 fork 来预测。
获取 tasklist_lock —— 原子可见性
// line 2369
write_lock_irq(&tasklist_lock);
从此处开始,copy_process() 持有全局的 tasklist_lock 写锁。这个锁保护进程树结构的完整性,确保在修改父子关系时不会有其他进程遍历进程树。
设置父进程关系
// line 2372-2383
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent;
p->parent_exec_id = current->parent_exec_id;
if (clone_flags & CLONE_THREAD)
p->exit_signal = -1;
else
p->exit_signal = current->group_leader->exit_signal;
} else {
p->real_parent = current;
p->parent_exec_id = current->self_exec_id;
p->exit_signal = args->exit_signal;
}
- CLONE_THREAD:父进程是 current 的 real_parent(即主线程的父进程),exit_signal 设为 -1(线程退出不发信号)
- CLONE_PARENT(非线程):父进程是 current 的 real_parent(成为兄弟),继承 exit_signal
- fork()(默认):父进程就是 current,exit_signal 是 SIGCHLD
加入全局进程表
// line 2416-2455
init_task_pid_links(p);
if (likely(p->pid)) {
ptrace_init_task(p, (clone_flags & CLONE_PTRACE) || trace);
init_task_pid(p, PIDTYPE_PID, pid);
if (thread_group_leader(p)) {
// 线程组领导者(新进程)
init_task_pid(p, PIDTYPE_TGID, pid);
init_task_pid(p, PIDTYPE_PGID, task_pgrp(current));
init_task_pid(p, PIDTYPE_SID, task_session(current));
if (is_child_reaper(pid)) {
ns_of_pid(pid)->child_reaper = p;
p->signal->flags |= SIGNAL_UNKILLABLE;
}
p->signal->shared_pending.signal = delayed.signal;
p->signal->tty = tty_kref_get(current->signal->tty);
p->signal->has_child_subreaper = ...;
list_add_tail(&p->sibling, &p->real_parent->children);
list_add_tail_rcu(&p->tasks, &init_task.tasks);
attach_pid(p, PIDTYPE_TGID);
attach_pid(p, PIDTYPE_PGID);
attach_pid(p, PIDTYPE_SID);
__this_cpu_inc(process_counts);
} else {
// 新线程(非领导者)
current->signal->nr_threads++;
current->signal->quick_threads++;
atomic_inc(¤t->signal->live);
refcount_inc(¤t->signal->sigcnt);
task_join_group_stop(p);
list_add_tail_rcu(&p->thread_node,
&p->signal->thread_head);
}
attach_pid(p, PIDTYPE_PID);
nr_threads++;
}
这段代码是进程创建的 "发布" 阶段,将新进程连接到内核的各个管理数据结构中:
-
PID 类型链接:每个进程有四种 PID 类型——PID(进程ID)、TGID(线程组ID)、PGID(进程组ID)、SID(会话ID)。新进程继承父进程的进程组和会话。
-
子进程收割者:如果新进程是 PID 命名空间的第一个进程(is_child_reaper),它成为该命名空间的 init 进程,获得 SIGNAL_UNKILLABLE 标志。
-
延迟信号转移:
p->signal->shared_pending.signal = delayed.signal将 fork 期间积压的多进程信号转移到子进程。 -
进程树插入: -
list_add_tail(&p->sibling, &p->real_parent->children)加入父进程的子进程链表 -list_add_tail_rcu(&p->tasks, &init_task.tasks)加入全局进程链表 -
PID 哈希表插入:
attach_pid()将 PID 插入内核的 PID 哈希表,使得通过 PID 号可以快速查找 task_struct。
最终完成
// line 2456-2476
total_forks++;
hlist_del_init(&delayed.node);
spin_unlock(¤t->sighand->siglock);
syscall_tracepoint_update(p);
write_unlock_irq(&tasklist_lock);
if (pidfile)
fd_install(pidfd, pidfile);
proc_fork_connector(p);
sched_post_fork(p);
cgroup_post_fork(p, args);
perf_event_fork(p);
trace_task_newtask(p, clone_flags);
uprobe_copy_process(p, clone_flags);
user_events_fork(p, clone_flags);
copy_oom_score_adj(clone_flags, p);
return p;
在释放 tasklist_lock 之后,内核执行一系列 "通知" 操作: - proc_fork_connector:通过 connector 通知用户空间(供 top/ps 等工具使用) - sched_post_fork:完成调度器的 fork 后处理 - cgroup_post_fork:完成 cgroup 的 fork 后处理 - perf_event_fork:通知性能监控子系统
至此,copy_process() 成功返回新创建的 task_struct 指针。
10.3.10 错误处理 —— goto 链
copy_process() 的错误处理使用了经典的 "goto 链" 模式。每个 copy_ 函数都有对应的 bad_fork_ 清理标签,资源按创建的逆序释放:
// line 2478-2540(简化版)
bad_fork_core_free:
sched_core_free(p);
spin_unlock(¤t->sighand->siglock);
write_unlock_irq(&tasklist_lock);
bad_fork_cancel_cgroup:
cgroup_cancel_fork(p, args);
bad_fork_put_pidfd:
if (clone_flags & CLONE_PIDFD) {
fput(pidfile);
put_unused_fd(pidfd);
}
bad_fork_free_pid:
if (pid != &init_struct_pid)
free_pid(pid);
bad_fork_cleanup_thread:
exit_thread(p);
bad_fork_cleanup_io:
if (p->io_context)
exit_io_context(p);
bad_fork_cleanup_namespaces:
exit_nsproxy_namespaces(p);
bad_fork_cleanup_mm:
if (p->mm) {
mm_clear_owner(p->mm, p);
mmput(p->mm);
}
bad_fork_cleanup_signal:
if (!(clone_flags & CLONE_THREAD))
free_signal_struct(p->signal);
bad_fork_cleanup_sighand:
__cleanup_sighand(p->sighand);
bad_fork_cleanup_fs:
exit_fs(p);
bad_fork_cleanup_files:
exit_files(p);
bad_fork_cleanup_semundo:
exit_sem(p);
bad_fork_cleanup_security:
security_task_free(p);
bad_fork_cleanup_audit:
audit_free(p);
bad_fork_cleanup_perf:
perf_event_free_task(p);
bad_fork_sched_cancel_fork:
sched_cancel_fork(p);
bad_fork_cleanup_policy:
lockdep_free_task(p);
#ifdef CONFIG_NUMA
mpol_put(p->mempolicy);
#endif
bad_fork_cleanup_delayacct:
io_uring_free(p);
delayacct_tsk_free(p);
bad_fork_cleanup_count:
dec_rlimit_ucounts(task_ucounts(p), UCOUNT_RLIMIT_NPROC, 1);
exit_cred_namespaces(p);
exit_creds(p);
bad_fork_free:
WRITE_ONCE(p->__state, TASK_DEAD);
exit_task_stack_account(p);
put_task_stack(p);
delayed_free_task(p);
fork_out:
return ERR_PTR(retval);
这个 goto 链的设计确保了:
-
逆序释放:最后创建的资源最先被释放。例如,如果 copy_thread() 失败,之前成功创建的 copy_io、copy_namespaces、copy_mm 等都会被正确清理。
-
条件清理:某些清理操作只在特定条件下执行。例如
free_signal_struct只在非 CLONE_THREAD 时调用(因为 CLONE_THREAD 没有创建新的 signal_struct)。 -
原子性:copy_process() 要么完全成功(返回有效的 task_struct),要么完全失败(所有已分配资源被清理)。不存在 "部分创建" 的中间状态。
这个 goto 链是 C 语言中结构化错误处理的经典范例。虽然 Linux 内核编码风格明确禁止将 goto 用于其他目的,但在错误处理中使用 goto 是被鼓励的,因为它比嵌套的 if-else 更清晰,也避免了重复的清理代码。
10.3.11 资源复制总结
将 copy_process() 的资源复制序列整理如下:
| 顺序 | 函数 | CLONE 标志 | 共享行为 | 复制行为 |
|---|---|---|---|---|
| 1 | dup_task_struct | — | — | 分配 task_struct + 内核栈 |
| 2 | copy_creds | — | CLONE_THREAD 时共享 | 复制 uid/gid/capabilities |
| 3 | sched_fork | — | — | 初始化调度实体 |
| 4 | copy_semundo | CLONE_SYSVSEM | 引用计数 +1 | 分配新 semundo |
| 5 | copy_files | CLONE_FILES | atomic_inc(&count) | dup_fd() 创建新 fd 表 |
| 6 | copy_fs | CLONE_FS | users++ | copy_fs_struct() |
| 7 | copy_sighand | CLONE_SIGHAND | refcount_inc | kmem_cache_alloc + memcpy |
| 8 | copy_signal | CLONE_THREAD | 直接返回 | kmem_cache_zalloc |
| 9 | copy_mm | CLONE_VM | mmget(oldmm) | dup_mm() 含 COW 页表 |
| 10 | copy_namespaces | CLONE_NEW* | 共享未指定的 ns | 创建新命名空间 |
| 11 | copy_io | CLONE_IO | 引用计数 +1 | 创建新 io_context |
| 12 | copy_thread | — | — | 设置 CPU 寄存器 |
| 13 | alloc_pid | — | — | 分配 PID |
| 14 | cgroup_can_fork | CLONE_INTO_CGROUP | — | 检查 cgroup 限制 |
这个顺序不是随意的。它遵循以下原则:
- 先轻后重:先复制轻量级资源(凭证、文件描述符),后复制重量级资源(地址空间、命名空间)
- 先无副作用后有副作用:先做不会失败的操作,后做可能失败的操作。一旦 alloc_pid 成功,后续操作基本不会失败
- 依赖关系:copy_signal 依赖 copy_sighand(线程共享信号结构不需要独立的 signal_struct),copy_thread 在最后因为它是架构相关的,需要前面的资源都已就绪
小结
copy_process() 是 Linux 进程创建的完整实现。它从一个看似简单的函数调用出发,涉及内核几乎所有子系统的初始化。其设计体现了几个重要的工程原则:
关注点分离:copy_process() 只负责创建,不负责调度。kernel_clone() 负责调度和等待。这种分离使得 copy_process() 可以被 kernel_thread()、fork_idle() 等多种场景复用。
渐进式构建:资源按依赖关系逐个创建。每一步都可能失败,goto 链确保已分配的资源被正确清理。
共享 vs 复制的一致模式:每个 copy_ 函数都遵循相同的模式——检查 CLONE_ 标志,共享则增加引用计数,复制则创建新实例。这种一致性使得添加新的可共享资源变得简单。
原子可见性:新进程在获取 tasklist_lock 后才被插入全局进程表。在此之前,其他 CPU 上的代码无法看到这个新进程。这保证了进程树的原子性——不存在 "已创建但尚未完全初始化" 的进程。
理解了 copy_process() 的完整流程,也就理解了 Linux 进程创建的全貌。从 fork() 的三行代码到 copy_process() 的五百行实现,这个跨度正是 Linux 内核设计的精髓——简洁的接口背后是精密的实现。
10.4 写时复制 (COW) 机制详解
写时复制(Copy-On-Write,简称 COW)是 Linux 内核中最为精妙的内存优化技术之一。它深刻地改变了 fork() 系统调用的性能特征,使得进程创建的开销从与父进程地址空间大小成正比降低为仅与页表结构大小成正比。本节将从设计动机、实现机制、触发路径到边界情况,全面剖析 COW 在 Linux 7.0.10 内核中的完整实现。
10.4.1 COW 的设计动机
传统 fork() 的性能困境
在没有 COW 机制的传统实现中,fork() 必须将父进程地址空间中的所有内存页面完整地复制到子进程。考虑一个拥有 1GB 虚拟地址空间的进程:
1GB 地址空间 = 262,144 个 4KB 页面
复制操作:
- 每页 ~8ns (memcpy 一个 4KB 页面)
- 总计:262,144 × 8ns ≈ 2ms
但这仅仅是 memcpy 的时间,还不包括:
- 分配新物理页面的开销
- 页表修改和 TLB 刷新
- 内存控制组 (memcg) 计费
- 反向映射 (rmap) 更新
在实际场景中,一次 1GB 进程的 fork() 可能需要 5-10ms 甚至更长时间。这对性能敏感的应用来说是不可接受的。
fork() 后紧跟 exec() 的常见模式
更为关键的是,绝大多数 fork() 调用之后都会紧跟一个 exec() 系统调用来加载新的程序映像。这意味着:
传统方式 (无 COW):
fork() → 复制全部 1GB 页面 (耗时 ~5ms)
exec() → 丢弃全部复制的页面 (浪费!)
COW 方式:
fork() → 仅复制页表,共享物理页面 (耗时 ~200μs)
exec() → 丢弃子进程页表,加载新程序 (无浪费!)
COW 的核心洞察在于:不要在 fork() 时复制页面,而是在实际写入时才复制。如果 fork() 之后立即执行 exec(),那么子进程永远不会写入父进程的页面,COW 复制就永远不会发生,实现了零浪费。
COW 的基本策略
fork() 之前
==========
父进程页表
+---------+
虚拟页 A ----> | Frame X | [RW] 唯一拥有者
虚拟页 B ----> | Frame Y | [RW] 唯一拥有者
+---------+
fork() 之后 (COW)
================
父进程页表 子进程页表
+---------+ +---------+
虚拟页 A ----> | Frame X | [R] <=== 虚拟页 A | [R] 共享!
虚拟页 B ----> | Frame Y | [R] <=== 虚拟页 B | [R] 共享!
+---------+ +---------+
关键变化:
1. 父子页表指向同一组物理帧
2. 所有页面被标记为只读 (R)
3. 页面引用计数增加
子进程写入页 A 时
================
父进程页表 子进程页表
+---------+ +---------+
虚拟页 A ----> | Frame X | [RW] 虚拟页 A | Frame Z | [RW] 独立!
虚拟页 B ----> | Frame Y | [R] <=== 虚拟页 B | [R] 仍共享
+---------+ +---------+
写时复制触发:
1. 子进程写页 A → 页面错误
2. 分配新帧 Z,从帧 X 复制内容
3. 子进程 PTE 指向帧 Z,标记为可写
4. 父进程 PTE 恢复可写 (因帧 X 已无共享者)
10.4.2 fork() 中的 COW 实现:自顶向下
copy_mm():内存管理的入口点
fork() 的内存管理入口是 kernel/fork.c 中的 copy_mm() 函数(第 1557 行):
// kernel/fork.c, 第 1557 行
static int copy_mm(u64 clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;
tsk->min_flt = tsk->maj_flt = 0;
tsk->nvcsw = tsk->nivcsw = 0;
#ifdef CONFIG_DETECT_HUNG_TASK
tsk->last_switch_count = tsk->nvcsw + tsk->nivcsw;
tsk->last_switch_time = 0;
#endif
tsk->mm = NULL;
tsk->active_mm = NULL;
/*
* Are we cloning a kernel线程?
*
* We need to steal a active VM for that..
*/
oldmm = current->mm;
if (!oldmm)
return 0;
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm);
if (!mm)
return -ENOMEM;
}
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
}
这里有一个关键的分支判断:
- CLONE_VM 标志设置(线程创建):直接共享父进程的整个
mm_struct,调用mmget()增加引用计数。线程之间共享地址空间,不存在 COW。 - CLONE_VM 未设置(进程创建):调用
dup_mm()创建新的mm_struct并复制页表,这就是 COW 开始发挥作用的地方。
dup_mm():分配新的地址空间描述符
// kernel/fork.c, 第 1516 行
static struct mm_struct *dup_mm(struct task_struct *tsk,
struct mm_struct *oldmm)
{
struct mm_struct *mm;
int err;
mm = allocate_mm();
if (!mm)
goto fail_nomem;
memcpy(mm, oldmm, sizeof(*mm));
if (!mm_init(mm, tsk, mm->user_ns))
goto fail_nomem;
uprobe_start_dup_mmap();
err = dup_mmap(mm, oldmm);
if (err)
goto free_pt;
uprobe_end_dup_mmap();
mm->hiwater_rss = get_mm_rss(mm);
mm->hiwater_vm = mm->total_vm;
if (mm->binfmt && !try_module_get(mm->binfmt->module))
goto free_pt;
return mm;
// ... 错误处理路径省略
}
dup_mm() 首先分配一个新的 mm_struct,然后将父进程的 mm_struct 整体拷贝过来。接着调用 mm_init() 初始化新 mm_struct 中的各种锁、计数器等字段。关键步骤是调用 dup_mmap(),它负责复制 VMA 链和页表。
dup_mmap():复制 VMA 和页表的核心
dup_mmap() 定义在 mm/mmap.c 第 1732 行,是 COW 流程中最复杂的函数之一:
// mm/mmap.c, 第 1732 行
__latent_entropy int dup_mmap(struct mm_struct *mm, struct mm_struct *oldmm)
{
struct vm_area_struct *mpnt, *tmp;
int retval;
unsigned long charge = 0;
LIST_HEAD(uf);
VMA_ITERATOR(vmi, mm, 0);
if (mmap_write_lock_killable(oldmm))
return -EINTR;
flush_cache_dup_mm(oldmm);
uprobe_dup_mmap(oldmm, mm);
mmap_write_lock_nested(mm, SINGLE_DEPTH_NESTING);
dup_mm_exe_file(mm, oldmm);
mm->total_vm = oldmm->total_vm;
mm->data_vm = oldmm->data_vm;
mm->exec_vm = oldmm->exec_vm;
mm->stack_vm = oldmm->stack_vm;
retval = __mt_dup(&oldmm->mm_mt, &mm->mm_mt, GFP_KERNEL);
if (unlikely(retval))
goto out;
mt_clear_in_rcu(vmi.mas.tree);
for_each_vma(vmi, mpnt) {
struct file *file;
retval = vma_start_write_killable(mpnt);
if (retval < 0)
goto loop_out;
if (mpnt->vm_flags & VM_DONTCOPY) {
// 跳过标记为不可复制的 VMA
retval = vma_iter_clear_gfp(&vmi, mpnt->vm_start,
mpnt->vm_end, GFP_KERNEL);
if (retval)
goto loop_out;
vm_stat_account(mm, mpnt->vm_flags, -vma_pages(mpnt));
continue;
}
// ... 省略 memcg 计费和 VMA 复制 ...
tmp = vm_area_dup(mpnt);
// ... 省略策略复制、userfaultfd 处理 ...
if (tmp->vm_flags & VM_WIPEONFORK) {
tmp->anon_vma = NULL;
} else if (anon_vma_fork(tmp, mpnt))
goto fail_nomem_anon_vma_fork;
// ... 省略文件映射处理 ...
if (!(tmp->vm_flags & VM_WIPEONFORK))
retval = copy_page_range(tmp, mpnt);
if (retval) {
mpnt = vma_next(&vmi);
goto loop_out;
}
}
// ... 后续处理 ...
}
dup_mmap() 遍历父进程的每一个 VMA,对每个 VMA:
- 跳过
VM_DONTCOPY标记的 VMA(如内核映射) - 分配新的 VMA 结构并复制属性
- 调用
anon_vma_fork()建立反向映射的复制关系 - 调用
copy_page_range()复制页表 —— 这是 COW 的核心入口
vma_needs_copy():是否需要复制页表?
在进入页表复制之前,内核会先检查该 VMA 是否真的需要复制页表:
// mm/memory.c, 第 1478 行
static bool
vma_needs_copy(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma)
{
if (dst_vma->vm_flags & VM_COPY_ON_FORK)
return true;
/*
* The presence of an anon_vma indicates an anonymous VMA has page
* tables which naturally cannot be reconstituted on page fault.
*/
if (src_vma->anon_vma)
return true;
/*
* Don't copy ptes where a page fault will fill them correctly. Fork
* becomes much lighter when there are big shared or private readonly
* mappings. The tradeoff is that copy_page_range is more efficient
* than faulting.
*/
return false;
}
这个优化非常巧妙:
- 如果 VMA 没有关联的
anon_vma(即没有匿名页面被写入过),那么可以通过后续的缺页中断来按需填充页表,无需在 fork() 时复制。 - 对于大型只读文件映射或尚未写入的共享映射,这可以显著加速 fork()。
copy_page_range():四级页表遍历
当确认需要复制页表后,copy_page_range()(mm/memory.c 第 1503 行)沿四级页表自顶向下遍历:
// mm/memory.c, 第 1503 行
int
copy_page_range(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma)
{
pgd_t *src_pgd, *dst_pgd;
unsigned long addr = src_vma->vm_start;
unsigned long end = src_vma->vm_end;
struct mm_struct *dst_mm = dst_vma->vm_mm;
struct mm_struct *src_mm = src_vma->vm_mm;
struct mmu_notifier_range range;
bool is_cow;
int ret;
if (!vma_needs_copy(dst_vma, src_vma))
return 0;
if (is_vm_hugetlb_page(src_vma))
return copy_hugetlb_page_range(dst_mm, src_mm, dst_vma, src_vma);
is_cow = is_cow_mapping(src_vma->vm_flags);
if (is_cow) {
mmu_notifier_range_init(&range, MMU_NOTIFY_PROTECTION_PAGE,
0, src_mm, addr, end);
mmu_notifier_invalidate_range_start(&range);
vma_assert_write_locked(src_vma);
raw_write_seqcount_begin(&src_mm->write_protect_seq);
}
ret = 0;
dst_pgd = pgd_offset(dst_mm, addr);
src_pgd = pgd_offset(src_mm, addr);
do {
next = pgd_addr_end(addr, end);
if (pgd_none_or_clear_bad(src_pgd))
continue;
if (unlikely(copy_p4d_range(dst_vma, src_vma, dst_pgd, src_pgd,
addr, next))) {
ret = -ENOMEM;
break;
}
} while (dst_pgd++, src_pgd++, addr = next, addr != end);
// ... 后续处理 ...
}
这里有一个重要的判断:is_cow_mapping():
// include/linux/mm.h, 第 1931 行
static inline bool is_cow_mapping(vm_flags_t flags)
{
return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}
一个映射是 COW 映射的条件是:VM_MAYWRITE 置位但 VM_SHARED 未置位。这恰好对应了 MAP_PRIVATE 映射(私有映射允许写入但不是共享的)。当检测到 COW 映射时,内核会通知 MMU notifier(用于 KVM 等虚拟化场景),并开始一个写保护序列计数器,防止并发的 GUP-fast(get_user_pages_fast)在复制过程中看到不一致的状态。
页表遍历的调用链为:
copy_page_range()
└── copy_p4d_range() // 遍历 PGD 条目
└── copy_pud_range() // 遍历 P4D 条目
└── copy_pmd_range() // 遍历 PUD 条目
├── copy_huge_pmd() // 透明大页路径
└── copy_pte_range() // 普通页面路径
copy_pte_range():PTE 级别的复制
PTE 级别的复制是 COW 的真正实施点。copy_pte_range() 定义在 mm/memory.c 第 1221 行:
// mm/memory.c, 第 1221 行
static int
copy_pte_range(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma,
pmd_t *dst_pmd, pmd_t *src_pmd, unsigned long addr,
unsigned long end)
{
struct mm_struct *dst_mm = dst_vma->vm_mm;
struct mm_struct *src_mm = src_vma->vm_mm;
pte_t *orig_src_pte, *orig_dst_pte;
pte_t *src_pte, *dst_pte;
pmd_t dummy_pmdval;
pte_t ptent;
spinlock_t *src_ptl, *dst_ptl;
int progress, max_nr, ret = 0;
int rss[NR_MM_COUNTERS];
softleaf_t entry = softleaf_mk_none();
struct folio *prealloc = NULL;
int nr;
// ...
again:
progress = 0;
init_rss_vec(rss);
dst_pte = pte_alloc_map_lock(dst_mm, dst_pmd, addr, &dst_ptl);
if (!dst_pte) {
ret = -ENOMEM;
goto out;
}
src_pte = pte_offset_map_rw_nolock(src_mm, src_pmd, addr, &dummy_pmdval,
&src_ptl);
if (!src_pte) {
pte_unmap_unlock(dst_pte, dst_ptl);
goto out;
}
spin_lock_nested(src_ptl, SINGLE_DEPTH_NESTING);
orig_src_pte = src_pte;
orig_dst_pte = dst_pte;
lazy_mmu_mode_enable();
do {
nr = 1;
if (progress >= 32) {
progress = 0;
if (need_resched() ||
spin_needbreak(src_ptl) || spin_needbreak(dst_ptl))
break;
}
ptent = ptep_get(src_pte);
if (pte_none(ptent)) {
progress++;
continue;
}
if (unlikely(!pte_present(ptent))) {
ret = copy_nonpresent_pte(dst_mm, src_mm,
dst_pte, src_pte,
dst_vma, src_vma,
addr, rss);
// ... 处理非 present PTE (swap entry 等) ...
}
max_nr = (end - addr) / PAGE_SIZE;
ret = copy_present_ptes(dst_vma, src_vma, dst_pte, src_pte,
ptent, addr, max_nr, rss, &prealloc);
if (unlikely(ret == -EAGAIN || ret == -EHWPOISON))
break;
// ... 预分配页面处理 ...
nr = ret;
progress += 8 * nr;
} while (dst_pte++, src_pte++, addr += PAGE_SIZE * nr, addr != end);
// ... 后续处理 ...
}
这个函数同时持有源和目标页表的自旋锁(使用 SINGLE_DEPTH_NESTING 避免死锁检测的误报),逐个 PTE 进行复制。每处理 32 个 PTE 就检查一次是否需要调度或释放锁,以避免长时间持有自旋锁。
对于每个 PTE,有三种处理路径:
1. 空 PTE (pte_none):直接跳过,不需要复制
2. 非 present PTE:调用 copy_nonpresent_pte() 处理 swap entry、迁移条目等特殊情况
3. present PTE:调用 copy_present_ptes() —— 这是 COW 的核心
__copy_present_ptes():COW 写保护的实施
最核心的 COW 操作发生在 __copy_present_ptes() 中(mm/memory.c 第 1095 行):
// mm/memory.c, 第 1095 行
static __always_inline void __copy_present_ptes(struct vm_area_struct *dst_vma,
struct vm_area_struct *src_vma, pte_t *dst_pte, pte_t *src_pte,
pte_t pte, unsigned long addr, int nr)
{
struct mm_struct *src_mm = src_vma->vm_mm;
/* If it's a COW mapping, write protect it both processes. */
if (is_cow_mapping(src_vma->vm_flags) && pte_write(pte)) {
wrprotect_ptes(src_mm, addr, src_pte, nr);
pte = pte_wrprotect(pte);
}
/* If it's a shared mapping, mark it clean in the child. */
if (src_vma->vm_flags & VM_SHARED)
pte = pte_mkclean(pte);
pte = pte_mkold(pte);
if (!userfaultfd_wp(dst_vma))
pte = pte_clear_uffd_wp(pte);
set_ptes(dst_vma->vm_mm, addr, dst_pte, pte, nr);
}
这段代码揭示了 COW 的精髓:
- 检查是否为 COW 映射:通过
is_cow_mapping(src_vma->vm_flags)判断 - 检查页面是否可写:
pte_write(pte)检查当前 PTE 的可写位 - 双向写保护:
-
wrprotect_ptes(src_mm, addr, src_pte, nr):将父进程的 PTE 改为只读 -pte = pte_wrprotect(pte):将准备写入子进程的 PTE 也标记为只读 - 设置子进程的 PTE:
set_ptes()将修改后的 PTE 写入子进程页表
注意这里的 "both processes" 注释:COW 不仅保护子进程的页面,还要保护父进程的页面。因为在 fork() 之后,父子进程的 PTE 指向同一个物理帧,如果父进程的 PTE 仍然可写,父进程就可以直接修改该帧,影响子进程看到的内存内容。
copy_present_ptes():处理 GUP pin 和批量复制
copy_present_ptes() 函数(mm/memory.c 第 1126 行)在调用 __copy_present_ptes() 之前,还需要处理一个重要的边界情况——被 GUP (get_user_pages) pin 住的页面:
// mm/memory.c, 第 1126 行
static inline int
copy_present_ptes(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma,
pte_t *dst_pte, pte_t *src_pte, pte_t pte, unsigned long addr,
int max_nr, int *rss, struct folio **prealloc)
{
fpb_t flags = FPB_MERGE_WRITE;
struct page *page;
struct folio *folio;
int err, nr;
page = vm_normal_page(src_vma, addr, pte);
if (unlikely(!page))
goto copy_pte;
folio = page_folio(page);
// 大 folio 批量处理路径
if (unlikely(!*prealloc && folio_test_large(folio) && max_nr != 1)) {
// ... 批量复制连续 PTE ...
nr = folio_pte_batch_flags(folio, src_vma, src_pte, &pte, max_nr, flags);
folio_ref_add(folio, nr);
if (folio_test_anon(folio)) {
if (unlikely(folio_try_dup_anon_rmap_ptes(folio, page,
nr, dst_vma, src_vma))) {
folio_ref_sub(folio, nr);
return -EAGAIN; // 需要立即复制
}
rss[MM_ANONPAGES] += nr;
} else {
folio_dup_file_rmap_ptes(folio, page, nr, dst_vma);
rss[mm_counter_file(folio)] += nr;
}
__copy_present_ptes(dst_vma, src_vma, dst_pte, src_pte, pte,
addr, nr);
return nr;
}
// 单 PTE 路径
folio_get(folio);
if (folio_test_anon(folio)) {
/*
* If this page may have been pinned by the parent process,
* copy the page immediately for the child so that we'll always
* guarantee the pinned page won't be randomly replaced in the
* future.
*/
if (unlikely(folio_try_dup_anon_rmap_pte(folio, page, dst_vma, src_vma))) {
/* Page may be pinned, we have to copy. */
folio_put(folio);
err = copy_present_page(dst_vma, src_vma, dst_pte, src_pte,
addr, rss, prealloc, page);
return err ? err : 1;
}
rss[MM_ANONPAGES]++;
} else {
folio_dup_file_rmap_pte(folio, page, dst_vma);
rss[mm_counter_file(folio)]++;
}
copy_pte:
__copy_present_ptes(dst_vma, src_vma, dst_pte, src_pte, pte, addr, 1);
return 1;
}
这里调用的 folio_try_dup_anon_rmap_pte()(定义在 include/linux/rmap.h 第 664 行)承担了两个关键职责:
// include/linux/rmap.h, 第 567 行 (核心实现)
static __always_inline int __folio_try_dup_anon_rmap(struct folio *folio,
struct page *page, int nr_pages, struct vm_area_struct *dst_vma,
struct vm_area_struct *src_vma, enum pgtable_level level)
{
bool maybe_pinned;
int i;
// 检查页面是否可能被 DMA pin 住
maybe_pinned = likely(!folio_is_device_private(folio)) &&
unlikely(folio_needs_cow_for_dma(src_vma, folio));
switch (level) {
case PGTABLE_LEVEL_PTE:
if (unlikely(maybe_pinned)) {
for (i = 0; i < nr_pages; i++)
if (PageAnonExclusive(page + i))
return -EBUSY; // 被 pin 住,不能 COW 共享
}
if (!folio_test_large(folio)) {
if (PageAnonExclusive(page))
ClearPageAnonExclusive(page); // 清除排他标志
atomic_inc(&folio->_mapcount); // 增加映射计数
break;
}
// ... 大 folio 处理 ...
}
return 0;
}
如果页面被 GUP pin 住了(DMA 正在使用该页面),则不能简单地通过写保护来实现 COW,因为 COW 可能导致 DMA 操作访问到错误的页面。在这种情况下,必须立即复制页面内容(copy_present_page()),而不是延迟到写入时。
10.4.3 COW 触发路径:写时缺页
当子进程(或父进程)尝试写入一个被 COW 保护的只读页面时,CPU 会触发页面错误(page fault),从而进入 COW 的写时复制路径。
缺页中断的处理流程
CPU 写入只读页面
│
▼
do_page_fault() // 缺页中断入口
│
▼
handle_mm_fault() // 高层缺页处理
│
▼
handle_pte_fault() // PTE 级别处理
│
├─► pte_none? → do_pte_missing()
├─► !pte_present? → do_swap_page()
├─► pte_protnone? → do_numa_page()
│
└─► FAULT_FLAG_WRITE && !pte_write?
│
▼
do_wp_page() // 写保护缺页处理
handle_pte_fault():识别 COW 缺页
handle_pte_fault()(mm/memory.c 第 6286 行)是识别 COW 缺页的关键:
// mm/memory.c, 第 6286 行
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
pte_t entry;
if (unlikely(pmd_none(*vmf->pmd))) {
vmf->pte = NULL;
vmf->flags &= ~FAULT_FLAG_ORIG_PTE_VALID;
} else {
pmd_t dummy_pmdval;
vmf->pte = pte_offset_map_rw_nolock(vmf->vma->vm_mm, vmf->pmd,
vmf->address, &dummy_pmdval,
&vmf->ptl);
if (unlikely(!vmf->pte))
return 0;
vmf->orig_pte = ptep_get_lockless(vmf->pte);
vmf->flags |= FAULT_FLAG_ORIG_PTE_VALID;
if (pte_none(vmf->orig_pte)) {
pte_unmap(vmf->pte);
vmf->pte = NULL;
}
}
if (!vmf->pte)
return do_pte_missing(vmf);
if (!pte_present(vmf->orig_pte))
return do_swap_page(vmf);
if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
return do_numa_page(vmf);
spin_lock(vmf->ptl);
entry = vmf->orig_pte;
if (unlikely(!pte_same(ptep_get(vmf->pte), entry))) {
update_mmu_tlb(vmf->vma, vmf->address, vmf->pte);
goto unlock;
}
if (vmf->flags & (FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)) {
if (!pte_write(entry))
return do_wp_page(vmf); // ← COW 缺页入口!
else if (likely(vmf->flags & FAULT_FLAG_WRITE))
entry = pte_mkdirty(entry);
}
// ... 后续处理 ...
}
COW 缺页的识别条件位于第 6344-6346 行:
if (vmf->flags & (FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE)) {
if (!pte_write(entry))
return do_wp_page(vmf);
当缺页原因为写入(FAULT_FLAG_WRITE)或取消共享(FAULT_FLAG_UNSHARE),且 PTE 的可写位未设置时,就进入了 do_wp_page() —— 写保护缺页处理函数。
do_wp_page():写保护缺页的核心决策
do_wp_page()(mm/memory.c 第 4162 行)是 COW 触发路径的决策中心:
// mm/memory.c, 第 4162 行
static vm_fault_t do_wp_page(struct vm_fault *vmf)
__releases(vmf->ptl)
{
const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
struct vm_area_struct *vma = vmf->vma;
struct folio *folio = NULL;
pte_t pte;
if (likely(!unshare)) {
if (userfaultfd_pte_wp(vma, ptep_get(vmf->pte))) {
// userfaultfd 写保护处理 ...
}
}
vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);
if (vmf->page)
folio = page_folio(vmf->page);
/* Shared mapping: 不执行 COW */
if (vma->vm_flags & (VM_SHARED | VM_MAYSHARE)) {
if (!vmf->page || is_fsdax_page(vmf->page)) {
vmf->page = NULL;
return wp_pfn_shared(vmf);
}
return wp_page_shared(vmf, folio);
}
/*
* Private mapping: 如果页面可以复用,直接修改 PTE 权限
* 而不需要复制页面内容
*/
if (folio && folio_test_anon(folio) &&
(PageAnonExclusive(vmf->page) || wp_can_reuse_anon_folio(folio, vma))) {
if (!PageAnonExclusive(vmf->page))
SetPageAnonExclusive(vmf->page);
if (unlikely(unshare)) {
pte_unmap_unlock(vmf->pte, vmf->ptl);
return 0;
}
wp_page_reuse(vmf, folio);
return 0;
}
/*
* Ok, we need to copy. Oh, well..
*/
if (folio)
folio_get(folio);
pte_unmap_unlock(vmf->pte, vmf->ptl);
return wp_page_copy(vmf); // ← 真正的 COW 复制!
}
do_wp_page() 有三条处理路径:
- 共享映射(
VM_SHARED | VM_MAYSHARE):共享映射不执行 COW,直接调用wp_page_shared()使 PTE 可写。 - 可复用的匿名页:如果页面只有一个映射者(
PageAnonExclusive或wp_can_reuse_anon_folio返回真),直接调用wp_page_reuse()修改 PTE 权限为可写,无需复制。 - 需要复制:调用
wp_page_copy()执行真正的页面复制。
路径 2 是一个重要的优化:如果父进程已经解除了映射(例如父进程已经退出),那么子进程就是该物理页面的唯一使用者,此时无需复制,只需恢复 PTE 的可写权限。
wp_page_copy():真正的写时复制
wp_page_copy()(mm/memory.c 第 3771 行)实现了物理页面的实际复制:
// mm/memory.c, 第 3771 行
static vm_fault_t wp_page_copy(struct vm_fault *vmf)
{
const bool unshare = vmf->flags & FAULT_FLAG_UNSHARE;
struct vm_area_struct *vma = vmf->vma;
struct mm_struct *mm = vma->vm_mm;
struct folio *old_folio = NULL;
struct folio *new_folio = NULL;
pte_t entry;
int page_copied = 0;
struct mmu_notifier_range range;
vm_fault_t ret;
bool pfn_is_zero;
delayacct_wpcopy_start();
if (vmf->page)
old_folio = page_folio(vmf->page);
ret = vmf_anon_prepare(vmf);
if (unlikely(ret))
goto out;
// 步骤 1: 分配新的物理页面
pfn_is_zero = is_zero_pfn(pte_pfn(vmf->orig_pte));
new_folio = folio_prealloc(mm, vma, vmf->address, pfn_is_zero);
if (!new_folio)
goto oom;
if (!pfn_is_zero) {
int err;
// 步骤 2: 从旧页面复制内容到新页面
err = __wp_page_copy_user(&new_folio->page, vmf->page, vmf);
if (err) {
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);
}
__folio_mark_uptodate(new_folio);
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);
// 步骤 3: 重新获取 PTE 锁并验证
vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);
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_might_unmap_zero_page(mm, vmf->orig_pte);
inc_mm_counter(mm, MM_ANONPAGES);
}
flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
// 步骤 4: 创建新的 PTE,指向新页面,标记为可写
entry = folio_mk_pte(new_folio, vma->vm_page_prot);
entry = pte_sw_mkyoung(entry);
if (unlikely(unshare)) {
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 {
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
}
// 步骤 5: 原子替换 PTE
ptep_clear_flush(vma, vmf->address, vmf->pte);
folio_add_new_anon_rmap(new_folio, vma, vmf->address, RMAP_EXCLUSIVE);
folio_add_lru_vma(new_folio, vma);
set_pte_at(mm, vmf->address, vmf->pte, entry);
update_mmu_cache_range(vmf, vma, vmf->address, vmf->pte, 1);
if (old_folio) {
// 步骤 6: 解除旧页面的映射关系
folio_remove_rmap_pte(old_folio, vmf->page, vma);
}
page_copied = 1;
pte_unmap_unlock(vmf->pte, vmf->ptl);
}
// ... 后续处理 ...
if (old_folio) {
if (page_copied)
free_swap_cache(old_folio);
folio_put(old_folio);
}
delayacct_wpcopy_end();
return 0;
}
wp_page_copy() 的完整执行步骤如下:
COW 写时复制的完整流程:
========================
写入前
======
当前进程 PTE ──→ Frame X (只读,共享)
mapcount = 2
步骤 1: 分配新页面
new_folio = folio_prealloc(...)
↓
步骤 2: 复制内容
__wp_page_copy_user(new, old)
将 Frame X 的内容复制到 Frame Z
↓
步骤 3: 获取 PTE 锁
验证 PTE 未被并发修改
↓
步骤 4: 构建新 PTE
entry = folio_mk_pte(new_folio, ...)
entry = maybe_mkwrite(pte_mkdirty(entry), vma)
// 新 PTE 指向 Frame Z,标记为可写
↓
步骤 5: 原子替换
ptep_clear_flush() → 清除旧 PTE,刷新 TLB
set_pte_at() → 设置新 PTE
↓
步骤 6: 更新映射关系
folio_remove_rmap_pte(old_folio, ...) // Frame X 的 mapcount--
folio_add_new_anon_rmap(new_folio, ...) // Frame Z 添加反向映射
↓
写入后
======
当前进程 PTE ──→ Frame Z (可写,独占)
mapcount = 1
其他进程 PTE ──→ Frame X (可写/只读)
mapcount = 1
wp_page_reuse():无需复制的快速路径
当页面实际上只有一个映射者时(例如另一个进程已经退出或取消了映射),wp_page_reuse() 可以直接修改 PTE 为可写,避免了不必要的页面复制:
// mm/memory.c, 第 3677 行
static inline void wp_page_reuse(struct vm_fault *vmf, struct folio *folio)
__releases(vmf->ptl)
{
struct vm_area_struct *vma = vmf->vma;
pte_t entry;
if (folio) {
folio_xchg_last_cpupid(folio, (1 << LAST_CPUPID_SHIFT) - 1);
}
flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
entry = pte_mkyoung(vmf->orig_pte);
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
update_mmu_cache_range(vmf, vma, vmf->address, vmf->pte, 1);
pte_unmap_unlock(vmf->pte, vmf->ptl);
count_vm_event(PGREUSE);
}
这条快速路径仅修改 PTE 的权限位,无需分配新页面、无需复制内容、无需更新反向映射,开销极小。
10.4.4 COW 与不同映射类型
COW 的行为因映射类型而异。不同的映射类型决定了 fork() 时是否需要 COW,以及写入时的处理方式。
匿名映射 (MAP_PRIVATE + 匿名)
匿名页面是 COW 的典型应用场景。fork() 时,所有匿名页面被标记为共享和只读,父子进程的 PTE 指向同一个物理帧。任何一方写入时触发 COW,复制出一个私有的物理帧。
匿名页面的 COW 生命周期:
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
→ 分配 VMA,无物理页面
写入页面
→ 缺页中断 → do_anonymous_page()
→ 分配物理帧 Frame X
→ PTE: [Present | Writable | Dirty]
fork()
→ COW: PTE 标记只读
→ Frame X: mapcount = 2
子进程写入
→ 缺页中断 → do_wp_page() → wp_page_copy()
→ 分配 Frame Z,复制内容
→ 子进程 PTE → Frame Z [Writable]
→ Frame X: mapcount = 1
→ 父进程 PTE → Frame X (可复用为可写)
文件映射 (MAP_PRIVATE + 文件)
私有文件映射的 COW 行为与匿名映射类似,但有一个重要区别:文件映射的页面来自页缓存(page cache)。在 COW 之前,多个进程可以通过页缓存共享同一物理页面。一旦触发 COW,进程将拥有一个独立的私有副本,脱离页缓存。
文件映射的 COW:
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0)
→ 文件页面从页缓存获取
fork()
→ COW: 页面标记只读
→ 页缓存页面: mapcount 增加
子进程写入文件页
→ COW: 分配新 Frame Z
→ 复制页缓存帧内容到 Frame Z
→ 子进程 PTE → Frame Z (匿名页面)
→ 页缓存帧: mapcount 减少
→ 注意: 子进程的修改不会写回文件!
共享映射 (MAP_SHARED)
共享映射从不触发 COW。这是 is_cow_mapping() 函数的直接结果:当 VM_SHARED 置位时,is_cow_mapping() 返回 false。fork() 时,共享映射的页面保持可写,父子进程看到完全相同的内容。
共享映射 (无 COW):
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0)
→ 文件页面从页缓存获取
fork()
→ 无 COW: PTE 保持可写
→ 父子进程共享页缓存帧
任一进程写入
→ 直接修改页缓存帧
→ 另一进程可见(因为共享同一物理帧)
→ 修改最终会写回文件
COW 映射判断总结
+-------------------+-----------+----------+-----------+
| 映射类型 | VM_SHARED | COW? | 写入行为 |
+-------------------+-----------+----------+-----------+
| MAP_PRIVATE|ANON | 0 | 是 | 复制页面 |
| MAP_PRIVATE|FILE | 0 | 是 | 复制页面 |
| MAP_SHARED|ANON | 1 | 否 | 直接写入 |
| MAP_SHARED|FILE | 1 | 否 | 直接写入 |
+-------------------+-----------+----------+-----------+
判断公式: is_cow_mapping = (VM_MAYWRITE) && !(VM_SHARED)
10.4.5 COW 的性能影响
fork() 性能对比
COW 对 fork() 性能的提升是巨大的。以下是一个概念性的性能对比:
场景: fork() 一个 1GB 进程
无 COW (传统方式):
复制 262,144 个 4KB 页面
- 分配物理页面: ~2ms
- memcpy: ~2ms
- 页表更新: ~0.5ms
- memcg 计费: ~0.5ms
- rmap 更新: ~1ms
总计: ~6ms
有 COW (Linux 方式):
复制页表结构 (不复制页面内容)
- 分配页表页: ~20μs (约 512 个 PTE 页)
- 复制 PTE 条目: ~100μs
- 写保护操作: ~50μs
- rmap 更新: ~30μs
总计: ~200μs
加速比: ~30x
fork() + exec() 场景 (最优情况):
fork(): ~200μs (COW,不复制页面)
exec(): 加载新程序
COW 复制次数: 0 (完美!)
页表复制 vs 页面复制
理解 COW 性能优势的关键在于区分 "页表复制" 和 "页面复制":
4级页表结构 (x86-64):
PGD → P4D → PUD → PMD → PTE → 物理帧
一个 PGD 条目覆盖 512GB
一个 P4D 条目覆盖 512GB
一个 PUD 条目覆盖 1GB
一个 PMD 条目覆盖 2MB
一个 PTE 条目覆盖 4KB
对于 1GB 地址空间:
- PGD 条目: 1
- P4D 条目: 1
- PUD 条目: 1
- PMD 条目: 512
- PTE 页: ~512 (但需要分配和复制)
- 物理帧: 262,144 (不复制!)
页表本身只占用约 512 × 4KB = 2MB
而物理页面总共 1GB
复制页表 vs 复制页面: 2MB vs 1GB
10.4.6 COW 与 KSM (Kernel Same-page Merging)
KSM 是 Linux 内核的另一项页面共享技术,它可以在不同进程之间合并内容完全相同的页面。KSM 与 COW 有紧密的交互关系。
KSM 的基本原理
KSM 页面合并:
进程 A: Page X (内容 "HELLO") ─┐
├── 合并为共享页面 Frame S
进程 B: Page Y (内容 "HELLO") ─┘
Frame S: mapcount = 2
两个 PTE 标记只读
任一进程写入:
→ COW: 分配私有副本
→ 写入进程 PTE → 新 Frame [Writable]
→ Frame S: mapcount = 1
KSM 与 fork COW 的交互
当 KSM 合并的页面发生在 fork() 之后时,可能出现 "双重 COW":
1. KSM 合并进程 A 和 B 的页面 → 共享 Frame S
2. 进程 A fork() 出子进程 C:
→ Frame S: mapcount 从 2 变为 3
→ 进程 A 和子进程 C 的 PTE 都指向 Frame S (只读)
3. 子进程 C 写入:
→ COW: 分配 Frame Z,复制 Frame S 的内容
→ 子进程 C PTE → Frame Z [Writable]
→ Frame S: mapcount 从 3 变为 2
4. 进程 A 写入:
→ COW: 分配 Frame W,复制 Frame S 的内容
→ 进程 A PTE → Frame W [Writable]
→ Frame S: mapcount 从 2 变为 1
→ 进程 B 仍然映射 Frame S
这个过程中:
- fork() 时一次 COW 保护
- 写入时两次 COW 复制
- Frame S 最终只被进程 B 使用
在 do_wp_page() 中,内核会检测 KSM 页面并统计 COW 事件:
// mm/memory.c, 第 4250 行
#ifdef CONFIG_KSM
if (folio && folio_test_ksm(folio))
count_vm_event(COW_KSM);
#endif
return wp_page_copy(vmf);
10.4.7 COW 的边界情况
父进程先写入
fork() 之后,如果父进程先于子进程写入共享页面,同样会触发 COW:
fork() 之后:
父进程 PTE → Frame X [R] (mapcount=2)
子进程 PTE → Frame X [R] (mapcount=2)
父进程先写入 Frame X:
→ 父进程缺页 → do_wp_page()
→ wp_page_copy(): 分配 Frame Z
→ 父进程 PTE → Frame Z [RW] (独占)
→ 子进程 PTE → Frame X [R] (mapcount=1)
→ 后续子进程可以复用 Frame X (无需 COW)
父进程和子进程在 COW 面前完全平等——谁先写入,谁就触发 COW。这与直觉可能不同:虽然页面属于父进程,但 fork() 后父进程的 PTE 同样被标记为只读。
多级 fork (多次共享)
如果进程 A fork() 出进程 B,B 又 fork() 出进程 C,物理页面的共享计数会持续增加:
A fork() → B:
Frame X: mapcount = 2 (A, B 共享)
B fork() → C:
Frame X: mapcount = 3 (A, B, C 共享)
C 写入 → COW:
Frame X: mapcount = 2 (A, B 仍共享)
Frame Z: mapcount = 1 (C 独占)
A 写入 → COW:
Frame X: mapcount = 1 (B 独占)
Frame W: mapcount = 1 (A 独占)
mapcount 准确跟踪了每个物理帧被多少个进程的页表所引用。
透明大页 (THP) 的 COW
透明大页(Transparent Huge Pages, THP)使用 2MB 的页面而非标准的 4KB 页面。COW 对 THP 的处理有两条路径:
- 整体复制:直接复制整个 2MB 大页,分配一个新的 2MB 帧
- 分裂后复制:先将大页分裂为 512 个普通页面,然后仅复制被写入的页面
在 copy_pmd_range() 中可以看到 THP COW 的处理入口:
// mm/memory.c, 第 1375 行
static inline int
copy_pmd_range(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma,
pud_t *dst_pud, pud_t *src_pud, unsigned long addr,
unsigned long end)
{
// ...
do {
next = pmd_addr_end(addr, end);
if (pmd_is_huge(*src_pmd)) {
int err;
VM_BUG_ON_VMA(next-addr != HPAGE_PMD_SIZE, src_vma);
err = copy_huge_pmd(dst_mm, src_mm, dst_pmd, src_pmd,
addr, dst_vma, src_vma);
if (err == -ENOMEM)
return -ENOMEM;
if (!err)
continue;
/* fall through */
}
if (pmd_none_or_clear_bad(src_pmd))
continue;
if (copy_pte_range(dst_vma, src_vma, dst_pmd, src_pmd,
addr, next))
return -ENOMEM;
} while (dst_pmd++, src_pmd++, addr = next, addr != end);
return 0;
}
对于 THP,copy_huge_pmd() 会在 PMD 级别执行 COW,将父进程的 PMD 标记为只读,子进程创建一个指向同一 2MB 帧的只读 PMD。
GUP Pin 与 COW 的冲突
get_user_pages (GUP) 是内核获取用户空间页面物理帧引用的机制,常用于 DMA 操作。当一个页面被 GUP pin 住时,COW 不能简单地替换该页面,否则 DMA 可能会写入错误的物理帧。
这就是 folio_try_dup_anon_rmap_pte() 检查 pin 的原因:
// include/linux/rmap.h, 第 585 行
maybe_pinned = likely(!folio_is_device_private(folio)) &&
unlikely(folio_needs_cow_for_dma(src_vma, folio));
当检测到页面被 pin 住时,fork() 会立即复制该页面(通过 copy_present_page()),而不是延迟到写入时:
// mm/memory.c, 第 1060 行
copy_present_page(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma,
pte_t *dst_pte, pte_t *src_pte, unsigned long addr, int *rss,
struct folio **prealloc, struct page *page)
{
struct folio *new_folio;
pte_t pte;
new_folio = *prealloc;
if (!new_folio)
return -EAGAIN;
// 从旧页面复制内容到新页面
if (copy_mc_user_highpage(&new_folio->page, page, addr, src_vma))
return -EHWPOISON;
*prealloc = NULL;
__folio_mark_uptodate(new_folio);
// 新页面作为子进程的独占匿名页
folio_add_new_anon_rmap(new_folio, dst_vma, addr, RMAP_EXCLUSIVE);
folio_add_lru_vma(new_folio, dst_vma);
rss[MM_ANONPAGES]++;
// 新 PTE 可写(子进程独占此页面)
pte = folio_mk_pte(new_folio, dst_vma->vm_page_prot);
pte = maybe_mkwrite(pte_mkdirty(pte), dst_vma);
// ...
}
在这种情况下,子进程在 fork() 时就拥有了独立的物理帧,绕过了 COW 延迟复制的优化。这是一个为了正确性而牺牲性能的必要折中。
10.4.8 COW 实现中的关键数据结构
PTE 中的 COW 标志
在 x86-64 架构上,COW 利用 PTE 中的以下标志位:
PTE (Page Table Entry) 格式 (x86-64):
+----+----+----+----+----+----+----+----+----+---+----+---+---+---+
| NX | .. | .. | .. | G | PS | D | A |PCD|PWT|US |RW |P |
+----+----+----+----+----+----+----+----+----+---+----+---+---+---+
63 7 6 5 4 3 2 1 0
COW 相关的标志位:
P (Present, bit 0): = 1 (页面在物理内存中)
RW (Read/Write, bit 1): = 0 (只读,触发 COW)
US (User/Supervisor, bit 2): 保持不变
D (Dirty, bit 6): 保持不变
COW 不使用特殊的 "COW" 标志位!
判断依据: PTE 只读 + VMA 可写 = COW 保护
COW 的判断完全依赖于 PTE 的可写位与 VMA 权限的不一致性。如果 VMA 允许写入(VM_WRITE),但 PTE 是只读的,那么缺页中断处理程序就知道这是一个 COW 保护。
页面的引用计数与映射计数
内核使用 folio (或 page) 结构体中的多个计数器来跟踪页面的共享状态:
struct folio {
atomic_t _mapcount; // 映射计数:被多少个 PTE 引用
atomic_t _refcount; // 引用计数:总的引用数
unsigned long _mm_ids; // MM 标识符,用于大页排他性判断
// ...
};
映射计数 (_mapcount) 的特殊值:
-1 (PAGE_MAPCOUNT_NOT_SET): 未映射
0: 只有一个 PTE 引用(独占)
1: 两个 PTE 引用(共享,如 COW)
N: N+1 个 PTE 引用
引用计数 (_refcount) 包含:
- 页表映射引用
- GUP/DMA pin 引用
- 页缓存引用
- 内核临时引用
anon_vma 与反向映射
anon_vma 是 COW 实现中不可或缺的数据结构。它建立了匿名页面与映射该页面的 VMA 之间的反向映射关系。
fork() 之前:
进程 A
VMA → anon_vma_A
Page X → anon_vma_A (通过 rmap)
fork() 之后:
进程 A 进程 B (子进程)
VMA → anon_vma_A VMA → anon_vma_B
anon_vma_B → parent = anon_vma_A
Page X 的 rmap 链:
→ anon_vma_A → 进程 A 的 VMA
→ anon_vma_B → 进程 B 的 VMA
通过 anon_vma 层次结构,可以从 Page X 找到所有映射它的 PTE
这对于 COW 复制后更新其他进程的 PTE 至关重要
anon_vma_fork() 在 dup_mmap() 中被调用,它为子进程创建新的 anon_vma 并将其链接到父进程的 anon_vma:
// mm/mmap.c, dup_mmap() 中
if (tmp->vm_flags & VM_WIPEONFORK) {
tmp->anon_vma = NULL;
} else if (anon_vma_fork(tmp, mpnt))
goto fail_nomem_anon_vma_fork;
write_protect_seq 序列计数器
在 copy_page_range() 中,内核使用了 write_protect_seq 序列计数器来与 GUP-fast 同步:
// mm/memory.c, copy_page_range() 中
if (is_cow) {
raw_write_seqcount_begin(&src_mm->write_protect_seq);
}
// ... 复制页表 ...
if (is_cow) {
raw_write_seqcount_end(&src_mm->write_protect_seq);
}
GUP-fast 在获取页面引用前会检查这个序列计数器:
COW (write_protect_seq): GUP-fast:
seqcount_begin() 读 seqcount
写保护 PTE 读 PTE
seqcount_end() 检查 seqcount 是否变化
如果变化 → 放弃,走慢路径
这确保了 GUP-fast 不会在 COW 写保护过程中获取到一个正在被修改的 PTE 状态。
10.4.9 COW 全景图
将所有内容串联起来,COW 的完整生命周期可以用以下时序图表示:
时间线 ──────────────────────────────────────────────────────────►
fork() 写入 exec()
│ │ │
▼ ▼ ▼
copy_mm() do_page_fault()
│ │
▼ ▼
dup_mm() handle_pte_fault()
│ │
├─ allocate_mm() ├─ pte_present? YES
├─ mm_init() ├─ FAULT_FLAG_WRITE? YES
└─ dup_mmap() └─ !pte_write? YES
│ │
├─ __mt_dup() (VMA 树) ▼
├─ anon_vma_fork() do_wp_page()
└─ copy_page_range() │
│ ├─ 共享映射? → wp_page_shared()
├─ vma_needs_copy() ├─ 独占页面? → wp_page_reuse()
├─ is_cow_mapping() └─ 共享页面? → wp_page_copy()
└─ copy_p4d_range() │
└─ copy_pte_range() ├─ folio_prealloc()
└─ copy_present_ptes() ├─ __wp_page_copy_user()
│ ├─ ptep_clear_flush()
├─ folio_try_dup_anon_rmap() ├─ set_pte_at()
│ ├─ mapcount++ └─ folio_remove_rmap_pte()
│ └─ ClearAnonExclusive
├─ wrprotect_ptes() (父进程)
└─ set_ptes() (子进程, 只读)
fork() 开销: COW 开销:
✓ 页表复制 (~200μs/GB) ✓ 页面分配
✓ mapcount 原子操作 ✓ memcpy (4KB)
✓ PTE 写保护 ✓ rmap 更新
✗ 不复制页面内容 ✗ 每次只复制被写入的页面
COW 机制完美体现了 Linux 内核设计中 "延迟到最后一刻" 的哲学:只有在确实需要时才执行昂贵的操作。通过对页面引用计数、PTE 权限位和反向映射的精巧管理,Linux 在 fork() 的正确性和性能之间取得了优雅的平衡。
10.5 vfork() 与 kernel_thread()
在前面几节中,我们详细分析了 fork() 和 clone() 系统调用的实现机制。这两个系统调用通过写时复制(Copy-On-Write, COW)技术高效地创建新进程。然而在 Linux 内核中,还有两种特殊的进程创建方式:vfork() 和 kernel_thread()。前者是一种更为激进的地址空间共享机制,后者则是内核自身创建后台工作线程的内部接口。本节将深入剖析这两种机制的实现原理、使用场景以及潜在的风险。
vfork()
10.5.1 为什么需要 vfork()
尽管现代 Linux 内核已经采用了写时复制技术来优化 fork() 的性能,但 fork() 仍然存在不可忽视的开销。写时复制虽然避免了立即复制物理页帧,但父进程的页表必须被完整复制。对于一个地址空间达到 1GB 的进程,假设使用标准的 4KB 页面和三级页表结构,内核需要复制大约 256K 个页表项(PTE)。这个操作涉及大量的内存分配和内存拷贝,即使在实际数据没有被复制的情况下,页表复制本身的开销也是相当可观的。
vfork() 采用了更为激进的策略:子进程完全共享父进程的地址空间,不进行任何页表复制。父进程在调用 vfork() 后会被阻塞,直到子进程调用 exec() 加载新程序或者调用 _exit() 终止。这种设计从根本上消除了页表复制的开销。
从历史角度来看,vfork() 的重要性更加突出。在 Linux 1.1.50 版本引入写时复制机制之前,fork() 会完整复制父进程的所有地址空间,这在内存紧张的系统上是极其昂贵的操作。vfork() 在那个时代几乎是创建子进程后立即执行 exec() 的唯一高效方式。即使在今天,在内存高度受限的嵌入式系统中,vfork() 仍然具有实际意义。
10.5.2 vfork() 系统调用的实现
vfork() 的系统调用入口定义在 kernel/fork.c 的第 2749 行:
// kernel/fork.c: 2748-2758
#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
#endif
这段代码简洁而精妙。vfork() 与 fork() 共用同一个 kernel_clone() 核心函数,但通过两个关键的标志位来实现完全不同的语义:
-
CLONE_VM(0x00000100):告诉内核子进程与父进程共享同一个内存地址空间(
mm_struct)。在copy_mm()函数中,当检测到此标志时,内核不会调用dup_mm()复制地址空间,而是简单地增加现有mm_struct的引用计数并直接共享。 -
CLONE_VFORK(0x00004000):告诉内核父进程必须等待子进程完成特定操作后才能继续执行。这个标志触发了父进程的阻塞/唤醒机制。
对比 fork() 的实现可以看得更清楚。fork() 的系统调用不设置任何 flags:
// kernel/fork.c: 2733-2745
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
#else
return -EINVAL;
#endif
}
在 copy_mm() 函数(kernel/fork.c 第 1557 行)中,两种调用方式的区别体现得非常明确:
// kernel/fork.c: 1557-1591
static int copy_mm(u64 clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;
tsk->mm = NULL;
tsk->active_mm = NULL;
// 如果是内核线程(current->mm == NULL),直接返回
oldmm = current->mm;
if (!oldmm)
return 0;
if (clone_flags & CLONE_VM) {
// vfork() 路径:共享地址空间,不复制
mmget(oldmm);
mm = oldmm;
} else {
// fork() 路径:完整复制地址空间(包含 COW)
mm = dup_mm(tsk, current->mm);
if (!mm)
return -ENOMEM;
}
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
}
当 CLONE_VM 被设置时,copy_mm() 仅仅增加了父进程 mm_struct 的引用计数(mmget(oldmm)),然后直接让子进程指向同一个 mm_struct。没有任何页表被分配或复制。
10.5.3 CLONE_VFORK 的阻塞与唤醒机制
CLONE_VFORK 标志的实现机制是 vfork() 的核心。在 kernel_clone() 函数(第 2612 行)中,我们可以看到完整的父子同步流程:
// kernel/fork.c: 2612-2697
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
// ...
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
// ...
// 步骤 1:设置完成量
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p); // 增加引用计数,防止子进程退出时被释放
}
// 步骤 2:唤醒子进程
wake_up_new_task(p);
// 步骤 3:父进程阻塞等待
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
return nr;
}
这个同步机制基于 Linux 内核的完成量(completion)原语,工作流程如下:
-
初始化阶段:
kernel_clone()在父进程的栈上分配一个struct completion vfork变量,并将其地址写入子进程的task_struct->vfork_done字段。同时调用get_task_struct(p)增加子进程的引用计数,防止子进程提前退出导致task_struct被释放。 -
阻塞阶段:
wait_for_vfork_done()将父进程置于TASK_KILLABLE | TASK_FREEZABLE状态,然后调用wait_for_completion_state()进入睡眠。父进程将一直等待,直到收到完成量的信号或者被致命信号杀死。
// kernel/fork.c: 1428-1446
static int wait_for_vfork_done(struct task_struct *child,
struct completion *vfork)
{
unsigned int state = TASK_KILLABLE|TASK_FREEZABLE;
int killed;
cgroup_enter_frozen();
killed = wait_for_completion_state(vfork, state);
cgroup_leave_frozen(false);
if (killed) {
task_lock(child);
child->vfork_done = NULL;
task_unlock(child);
}
put_task_struct(child);
return killed;
}
- 唤醒阶段:当子进程调用
_exit()退出或调用execve()加载新程序时,内核会调用mm_release()函数。该函数检测到vfork_done不为空后,调用complete_vfork_done()唤醒父进程:
// kernel/fork.c: 1415-1426
static void complete_vfork_done(struct task_struct *tsk)
{
struct completion *vfork;
task_lock(tsk);
vfork = tsk->vfork_done;
if (likely(vfork)) {
tsk->vfork_done = NULL;
complete(vfork); // 唤醒等待中的父进程
}
task_unlock(tsk);
}
mm_release() 函数在两个关键路径上被调用:
- 退出路径:
exit_mm_release()->mm_release(),当子进程调用_exit()时触发。 - 执行路径:
exec_mm_release()->mm_release(),当子进程调用execve()时触发。此时execve()已经为子进程设置了新的地址空间,父进程可以安全地继续使用原来的地址空间。
// kernel/fork.c: 1461-1504
static void mm_release(struct task_struct *tsk, struct mm_struct *mm)
{
uprobe_free_utask(tsk);
deactivate_mm(tsk, mm);
// 处理 clear_child_tid 的 futex 唤醒
if (tsk->clear_child_tid) {
if (atomic_read(&mm->mm_users) > 1) {
put_user(0, tsk->clear_child_tid);
do_futex(tsk->clear_child_tid, FUTEX_WAKE,
1, NULL, NULL, 0, 0);
}
tsk->clear_child_tid = NULL;
}
// 唤醒因 vfork() 而阻塞的父进程
if (tsk->vfork_done)
complete_vfork_done(tsk);
}
10.5.4 vfork() 的危险性
vfork() 的危险性根植于其核心设计——父子进程共享同一个地址空间,尤其是共享同一个栈。这意味着子进程的任何栈操作都会直接影响父进程的栈帧。
具体来说,子进程在调用 vfork() 后必须严格遵守以下限制:
- 不能从调用函数中返回。因为子进程的函数返回会修改父进程栈上的返回地址和局部变量,当父进程被唤醒后继续执行时,栈帧已被破坏,行为完全不可预测。
- 不能修改任何局部变量。所有局部变量都位于父进程的栈上,修改它们等于修改父进程的状态。
- 不能调用
exit()。标准库的exit()函数会执行各种清理操作(刷新 stdio 缓冲区、调用atexit()注册的函数等),这些操作都可能修改共享地址空间中的数据。必须使用系统调用级别的_exit()(或_Exit())。 - 唯一安全的操作:调用
exec()系列函数加载新程序,或者调用_exit()直接终止。
由于这些严格的限制,vfork() 在 POSIX 标准中被标记为废弃(obsolete),但出于向后兼容性和特定场景的性能需求,仍然被保留。现代的 posix_spawn() 接口在内部可能会使用 vfork() 来获得性能优势,同时为用户提供安全的封装。
10.5.5 vfork() 与 fork() 的对比
| 特性 | fork() | vfork() |
|---|---|---|
| 地址空间处理 | 写时复制(COW) | 完全共享 |
| 页表 | 完整复制 | 共享(不复制) |
| 父进程是否阻塞 | 否,父子并行执行 | 是,直到子进程 exec 或 _exit |
| 典型耗时 | 约 200 微秒(取决于地址空间大小) | 约 10 微秒 |
| 子进程能否修改变量 | 可以(COW 保护) | 绝对不能 |
| 子进程能否安全返回 | 可以 | 不能 |
| POSIX 状态 | 标准接口 | 废弃但仍支持 |
| 主要使用场景 | 通用进程创建 | 嵌入式系统、内存紧张环境 |
kernel_thread()
10.5.6 为什么需要内核线程
Linux 内核中有大量需要在后台持续运行的任务:内存回收守护进程 kswapd、通用工作队列工作者 kworker、ext4 文件系统日志线程 jbd2、CPU 间迁移辅助线程 migration、死锁检测看门狗 watchdog 等等。这些任务具有以下共同特征:
- 只在内核空间运行,不需要用户态地址空间。
- 需要被调度器调度,能够睡眠、阻塞和被唤醒。
- 需要独立的进程上下文(拥有自己的
task_struct和内核栈)。 - 在系统启动阶段或模块初始化时创建。
内核线程是满足这些需求的理想机制。每个内核线程是一个独立的调度实体,拥有自己的内核栈,但没有用户态地址空间(task_struct->mm 为 NULL)。当内核线程被调度运行时,它借用前一个用户进程的 active_mm 来访问内核页表。
10.5.7 kernel_thread() 函数的实现
kernel_thread() 函数定义在 kernel/fork.c 的第 2702 行:
// kernel/fork.c: 2699-2715
/*
* Create a kernel thread.
*/
pid_t kernel_thread(int (*fn)(void *), void *arg, const char *name,
unsigned long flags)
{
struct kernel_clone_args args = {
.flags = ((flags | CLONE_VM | CLONE_UNTRACED) & ~CSIGNAL),
.exit_signal = (flags & CSIGNAL),
.fn = fn,
.fn_arg = arg,
.name = name,
.kthread = 1,
};
return kernel_clone(&args);
}
注意这个函数的几个关键设计决策:
-
CLONE_VM:内核线程共享调用者的地址空间。由于内核空间对所有进程来说都是相同的,这个标志避免了不必要的地址空间操作。在
copy_mm()中,当调用者是内核线程(current->mm == NULL)时,copy_mm()会直接返回 0,不做任何地址空间处理。 -
CLONE_UNTRACED:防止
ptrace跟踪内核线程。内核线程是内核内部实现细节,不应被用户态调试器干扰。 -
CSIGNAL 处理:
flags参数的低 8 位是退出信号。代码通过(flags | ...) & ~CSIGNAL清除 flags 中的信号位,同时将信号位单独保存到exit_signal。 -
fn 和 fn_arg:新线程的入口函数和参数。这些信息会被存储到
kernel_clone_args结构中,随后传递给copy_process()->copy_thread()。 -
kthread = 1:标记新创建的是内核线程。在
copy_process()中,这个标志会使新进程的PF_KTHREAD标志被设置:
// kernel/fork.c: 2056-2058
p->flags &= ~PF_KTHREAD;
if (args->kthread)
p->flags |= PF_KTHREAD;
- name:线程名称,会被复制到
task_struct->comm字段中:
// kernel/fork.c: 2070-2071
if (args->name)
strscpy_pad(p->comm, args->name, sizeof(p->comm));
10.5.8 copy_thread() 对内核线程的处理
copy_thread() 是架构相关的函数,负责设置新进程的执行上下文。内核线程和用户线程在这个函数中走了完全不同的路径。我们分别来看 x86_64、ARM64 和 RISC-V 三种架构的实现。
x86_64 架构
在 x86_64 上,copy_thread() 定义在 arch/x86/kernel/process.c 第 170 行:
// arch/x86/kernel/process.c: 170-234
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
unsigned long sp = args->stack;
unsigned long tls = args->tls;
struct inactive_task_frame *frame;
struct fork_frame *fork_frame;
struct pt_regs *childregs;
childregs = task_pt_regs(p);
fork_frame = container_of(childregs, struct fork_frame, regs);
frame = &fork_frame->frame;
// 设置栈帧的返回地址为 ret_from_fork_asm
frame->ret_addr = (unsigned long) ret_from_fork_asm;
p->thread.sp = (unsigned long) fork_frame;
// ...
fpu_clone(p, clone_flags, args->fn, new_ssp);
// 内核线程路径
if (unlikely(p->flags & PF_KTHREAD)) {
p->thread.pkru = pkru_get_init_value();
memset(childregs, 0, sizeof(struct pt_regs));
kthread_frame_init(frame, args->fn, args->fn_arg);
return 0;
}
// ... 用户线程路径 ...
}
kthread_frame_init() 函数(定义在 arch/x86/include/asm/switch_to.h 第 81 行)将线程函数指针和参数保存到栈帧的 callee-saved 寄存器位置:
// arch/x86/include/asm/switch_to.h: 81-90
static inline void kthread_frame_init(struct inactive_task_frame *frame,
int (*fun)(void *), void *arg)
{
frame->bx = (unsigned long)fun; // rbx = 函数指针
#ifdef CONFIG_X86_32
frame->di = (unsigned long)arg; // 32位:edi = 参数
#else
frame->r12 = (unsigned long)arg; // 64位:r12 = 参数
#endif
}
当新创建的内核线程首次被调度运行时,它从 ret_from_fork_asm(定义在 arch/x86/entry/entry_64.S 第 228 行)开始执行:
// arch/x86/entry/entry_64.S: 228-260
/*
* A newly forked process directly context switches into this address.
*
* rax: prev task we switched from
* rbx: kernel thread func (NULL for user thread)
* r12: kernel thread arg
*/
SYM_CODE_START(ret_from_fork_asm)
UNWIND_HINT_END_OF_STACK
movq %rax, %rdi /* prev task */
movq %rsp, %rsi /* regs */
movq %rbx, %rdx /* fn (函数指针) */
movq %r12, %rcx /* fn_arg (参数) */
call ret_from_fork
/* ... 返回到用户态 ... */
jmp swapgs_restore_regs_and_return_to_usermode
SYM_CODE_END(ret_from_fork_asm)
ret_from_fork_asm 汇编代码将函数指针和参数从 callee-saved 寄存器移到调用约定规定的参数寄存器中,然后调用 C 函数 ret_from_fork()(第 151 行):
// arch/x86/kernel/process.c: 151-168
__visible void ret_from_fork(struct task_struct *prev, struct pt_regs *regs,
int (*fn)(void *), void *fn_arg)
{
schedule_tail(prev);
/* Is this a kernel thread? */
if (unlikely(fn)) {
fn(fn_arg);
/*
* A kernel thread is allowed to return here after successfully
* calling kernel_execve(). Exit to userspace to complete the
* execve() syscall.
*/
regs->ax = 0;
}
syscall_exit_to_user_mode(regs);
}
ret_from_fork() 首先调用 schedule_tail() 完成调度器相关的清理工作,然后检测 fn 是否为非空。对于内核线程,fn 不为空,直接调用 fn(fn_arg) 执行线程的主函数。当线程函数返回后,syscall_exit_to_user_mode() 会被调用。注意,对于纯粹的内核线程,由于它不会返回到用户态,syscall_exit_to_user_mode() 实际上会引导线程退出。但对于通过 kernel_execve() 转变为用户进程的线程(如 PID 1 的 init 进程),此路径允许它正常进入用户态。
ARM64 架构
ARM64 的 copy_thread() 定义在 arch/arm64/kernel/process.c 第 411 行。对于内核线程(args->fn 不为空),处理方式如下:
// arch/arm64/kernel/process.c: 490-514
} else {
/*
* A kthread has no context to ERET to, so ensure any buggy
* ERET is treated as an illegal exception return.
*/
memset(childregs, 0, sizeof(struct pt_regs));
childregs->pstate = PSR_MODE_EL1h | PSR_IL_BIT;
childregs->stackframe.type = FRAME_META_TYPE_FINAL;
// 将函数指针和参数保存到 callee-saved 寄存器
p->thread.cpu_context.x19 = (unsigned long)args->fn;
p->thread.cpu_context.x20 = (unsigned long)args->fn_arg;
}
// 统一设置入口点和栈指针
p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
p->thread.cpu_context.sp = (unsigned long)childregs;
p->thread.cpu_context.fp = (unsigned long)&childregs->stackframe;
ARM64 的设计利用了函数调用约定中的 callee-saved 寄存器 x19 和 x20 来保存线程函数指针和参数。pc 被设置为 ret_from_fork 的地址。当新线程首次被上下文切换进入运行时,cpu_context 中保存的寄存器值被恢复,CPU 从 ret_from_fork 开始执行,该函数从 x19 和 x20 中取出函数指针和参数进行调用。
注意 pstate 被设置为 PSR_MODE_EL1h | PSR_IL_BIT,这确保了内核线程运行在 EL1(内核态),且任何错误的 ERET 指令会被视为非法异常返回。
RISC-V 架构
RISC-V 的 copy_thread() 定义在 arch/riscv/kernel/process.c 第 240 行:
// arch/riscv/kernel/process.c: 255-263
if (unlikely(args->fn)) {
/* Kernel thread */
memset(childregs, 0, sizeof(struct pt_regs));
/* Supervisor, irqs on: */
childregs->status = SR_PP | SR_PIE;
p->thread.s[0] = (unsigned long)args->fn; // a0 位置:函数指针
p->thread.s[1] = (unsigned long)args->fn_arg; // a1 位置:参数
p->thread.ra = (unsigned long)ret_from_fork_kernel_asm;
}
RISC-V 使用 thread.s[0] 和 thread.s[1](对应 callee-saved 寄存器 s0/s1)保存函数指针和参数,thread.ra 设置为 ret_from_fork_kernel_asm。RISC-V 的实现区分了内核线程和用户线程的入口点:内核线程使用 ret_from_fork_kernel_asm,用户线程使用 ret_from_fork_user_asm。
对应的内核线程入口汇编:
// arch/riscv/kernel/process.c: 228-233
asmlinkage void ret_from_fork_kernel(void *fn_arg, int (*fn)(void *),
struct pt_regs *regs)
{
fn(fn_arg);
syscall_exit_to_user_mode(regs);
}
三种架构虽然具体实现不同,但核心思想一致:利用上下文切换时自动恢复的 callee-saved 寄存器来传递线程函数指针和参数,设置入口点为 ret_from_fork(或其变体),让新线程在被首次调度时自动跳转到正确的函数执行。
10.5.9 内核线程的生命周期
一个典型的内核线程从创建到终结经历以下阶段:
1. 创建阶段
内核线程通过两种方式创建。低级接口直接调用 kernel_thread(),高级接口使用 kthread_create() / kthread_run() 宏。在系统启动阶段,rest_init()(init/main.c 第 714 行)创建了两个最关键的内核线程:
// init/main.c: 715-738
pid = user_mode_thread(kernel_init, NULL, CLONE_FS); // PID 1: init 进程
// ...
pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES); // PID 2: kthreadd
注意 PID 1 的 init 进程通过 user_mode_thread() 创建(它类似 kernel_thread() 但不设置 PF_KTHREAD 标志),而 PID 2 的 kthreadd 则是一个真正的内核线程。
2. kthreadd:所有内核线程的父进程
kthreadd(定义在 kernel/kthread.c 第 787 行)是内核线程的守护进程,所有通过 kthread_create() 创建的内核线程都是它的子进程:
// kernel/kthread.c: 787-816
int kthreadd(void *unused)
{
struct task_struct *tsk = current;
set_task_comm(tsk, "kthreadd");
ignore_signals(tsk);
set_mems_allowed(node_states[N_MEMORY]);
current->flags |= PF_NOFREEZE;
cgroup_init_kthreadd();
kthread_affine_node();
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (list_empty(&kthread_create_list))
schedule();
__set_current_state(TASK_RUNNING);
spin_lock(&kthread_create_lock);
while (!list_empty(&kthread_create_list)) {
struct kthread_create_info *create;
create = list_entry(kthread_create_list.next,
struct kthread_create_info, list);
list_del_init(&create->list);
spin_unlock(&kthread_create_lock);
create_kthread(create); // 调用 kernel_thread() 创建新线程
// ...
}
}
return 0;
}
3. kthread_create() 高级接口
大多数内核子系统不直接调用 kernel_thread(),而是使用 kthread_create() 或 kthread_run() 宏。这个高级接口提供了更好的线程管理能力:
// kernel/kthread.c: 380-439
static int kthread(void *_create)
{
struct kthread_create_info *create = _create;
int (*threadfn)(void *data) = create->threadfn;
void *data = create->data;
struct kthread *self;
self = to_kthread(current);
self->full_name = create->full_name;
self->threadfn = threadfn;
self->data = data;
// 重置调度优先级
sched_setscheduler_nocheck(current, SCHED_NORMAL, ¶m);
// 通知创建者线程已就绪
__set_current_state(TASK_UNINTERRUPTIBLE);
create->result = current;
preempt_disable();
complete(done);
schedule_preempt_disabled();
preempt_enable();
self->started = 1;
// 应用 NUMA 亲和性
if (!(current->flags & PF_NO_SETAFFINITY) && !self->preferred_affinity)
kthread_affine_node();
// 执行实际的线程函数
ret = -EINTR;
if (!test_bit(KTHREAD_SHOULD_STOP, &self->flags)) {
cgroup_kthread_ready();
__kthread_parkme(self);
ret = threadfn(data);
}
kthread_exit(ret);
}
kthread() 函数是一个包装器,它完成了初始化工作后调用实际的线程函数。当线程函数返回或者收到停止信号时,通过 kthread_exit() 退出。
4. 退出阶段
内核线程通过 kthread_exit() 退出。对于直接使用 kernel_thread() 创建的线程,线程函数返回后,控制流回到 ret_from_fork() 中,然后通过 syscall_exit_to_user_mode() 最终调用 do_exit() 完成退出。
10.5.10 内核线程与用户进程的对比
| 特性 | 内核线程 | 用户进程/线程 |
|---|---|---|
| 地址空间 | mm = NULL,借用 active_mm |
拥有自己的 mm_struct |
| 运行特权级 | Ring 0(仅内核态) | Ring 3(用户态) |
| 能否睡眠 | 可以 | 可以 |
| 能否进行系统调用 | 不能(已在内核中) | 可以 |
| 创建方式 | kernel_thread() / kthread_create() |
fork() / clone() |
| PF_KTHREAD 标志 | 设置 | 不设置 |
| 可执行程序 | 内核函数 | 用户态二进制文件 |
| 典型生命周期 | 系统启动到关机 | 随用户操作创建和销毁 |
10.5.11 重要的内核线程
Linux 内核中存在大量内核线程,每个都承担着关键的系统职责。以下是一些最值得关注的内核线程:
PID 0:swapper(idle 进程)
每个 CPU 都有一个 idle 进程(PID 0),这是系统的第一个进程。在系统启动时,boot CPU 的 idle 进程通过 start_kernel() -> rest_init() 完成初始化工作,然后进入空闲循环。当 CPU 没有其他任务可运行时,调度器会将 CPU 交给 idle 进程。idle 进程的 task_struct 是静态分配的(init_task),不通过 kernel_thread() 创建。
PID 1:init(用户态 init 进程)
严格来说,PID 1 最初是一个特殊的用户态线程,通过 user_mode_thread(kernel_init, ...) 创建。kernel_init() 函数完成内核初始化的收尾工作后,通过 kernel_execve() 执行用户态的 init 程序(通常是 /sbin/init 或 systemd),从而转变为一个用户态进程。PID 1 是所有用户态进程的祖先进程。
PID 2:kthreadd(内核线程守护进程)
kthreadd 通过 kernel_thread() 在 rest_init() 中创建,是所有后续通过 kthread_create() 创建的内核线程的父进程。它维护一个请求队列,不断检查是否有新的内核线程创建请求。
其他重要内核线程:
- kswapd:每个 NUMA 节点一个,负责后台内存回收。当系统空闲内存低于阈值时被唤醒,扫描 LRU 链表回收页面。
- kworker:工作队列的工作者线程,执行内核中各种延迟工作。现代内核中
kworker线程按 CPU 和类型(普通、高优先级、不可绑定 CPU 等)动态创建。 - jbd2:ext4 文件系统的日志提交线程。负责将文件系统事务按序写入日志,确保文件系统的一致性。
- migration:每个 CPU 一个,负责在 CPU 之间迁移任务。在 CPU 热插拔场景中辅助任务迁移。
- watchdog:每个 CPU 一个,检测软死锁(soft lockup)和硬死锁(hard lockup)。如果某个 CPU 长时间未调度,watchdog 会发出警报。
小结
vfork() 和 kernel_thread() 代表了 Linux 内核进程创建机制的两个极端。vfork() 通过完全共享地址空间来追求极致的创建速度,但代价是严苛的使用限制和潜在的安全风险。kernel_thread() 则为内核自身提供了创建后台工作者的能力,这些线程没有用户态上下文,完全在内核空间运行,承担着系统管理的各种核心职责。
两者都复用了 kernel_clone() 这一统一的进程创建框架,通过不同的标志位组合(CLONE_VM、CLONE_VFORK、PF_KTHREAD 等)实现差异化的行为。这种设计体现了 Linux 内核"统一接口、参数驱动"的哲学——一套核心机制,通过精心设计的参数来覆盖从用户态 fork() 到内核线程创建的全部场景。