Skip to content
Go back

fork原理:进程创建的底层全链路

一句话结论(30s)

fork 的精髓是「一次调用,两次返回」,而它之所以廉价靠的是 COW(写时复制)。因为子进程复制的是页表而非物理内存——父子指向相同的物理页、双方标记只读——所以只有真正写入时才触发缺页复制那一页;子进程返回 0 也不是魔法,而是内核在子进程的内核栈上把 rax 硬编码成了 0。

核心原理(2min)

底层深入(5-10min)

fork 的魔法:一次调用,两次返回

fork 是 UNIX 世界中最独特的系统调用——它被调用一次,却返回两次。这种看似魔法的行为背后是一套精密的流程:

pid_t fork(void);
// 父进程中返回子进程的 PID(> 0)
// 子进程中返回 0
// 出错返回 -1

理解了 “一次调用两次返回” 的实现机制,就理解了 fork 的全部精髓。

思考:为什么 fork 能”一次调用、两次返回”?普通的函数调用一次就返回一次,但 fork 在返回前已经把进程”复制”成了两个——父子进程各自带着一份返回上下文。内核在父进程里让 fork 返回子进程 PID,在子进程里把返回寄存器 rax 硬编码成 0,于是同一个调用点”分叉”出两条返回路径。这不是魔法,而是”进程复制 + 返回值分别设置”的必然结果。

fork 的内部流程

当用户态代码调用 fork() 时,glibc 封装函数执行 syscall 指令进入内核,触发 sys_forkkernel_clone。内核执行以下步骤:

第一步:复制进程描述符

// 简化版 kernel_clone 的核心逻辑
task_struct *child = copy_process(current, ...);

copy_process 为子进程分配一个新的 task_struct(进程描述符),新 PID(通过 alloc_pid 从 PID 位图中分配),并初始化调度相关的字段(优先级、时间片等)。此时子进程的状态是 TASK_NEW(尚未就绪)。

第二步:复制虚拟地址空间(CoW)

这是开销最大的步骤。copy_mmdup_mmap 遍历父进程的所有 VMA(虚拟内存区域),为子进程构建一个相同的 VMA 树。

页表复制层面:

这一步不复制物理内存,只复制页表结构。细节已在上一篇 CoW 文章中深入分析。

思考:fork 为什么用 COW 而不是直接复制物理内存?因为 fork + exec 是最常见模式——子进程往往马上 exec 换掉整个内存镜像,如果 fork 时就把父进程几百 MB 内存全复制一份,马上又被 exec 丢弃,纯属浪费。COW 赌”大部分页面根本不会被改写”,所以只复制页表、共享物理页,真正写入时才按页复制。用”按需复制”换掉”全量复制”,fork 才从昂贵操作变成廉价操作。

第三步:复制文件描述符表

copy_files 遍历父进程的 files_struct->fdtable,将每个文件描述符的引用计数加 1。这意味着:

第四步:复制其他资源

第五步:设置子进程返回值

子进程运行时会做以下设置:

// 在内核栈上设置子进程的返回上下文
childregs->ax = 0;  // x86-64 的 rax 寄存器 = 0(fork 在子进程中返回 0)

而父进程的返回上下文保持不变——父进程中 fork 返回子进程的 PID。

第六步:子进程加入调度队列

wake_up_new_task 将子进程状态从 TASK_NEW 改为 TASK_RUNNING 并加入运行队列。内核可能立即调度子进程运行(子进程优先于父进程是常见的优化,因为子进程通常很快会调用 exec,避免父进程后续写操作触发不必要的 CoW 复制)。

为什么子进程返回 0

这是面试中的高频问题。答案有两层:

技术层面:因为内核在子进程的内核栈上显式将 rax 设置为 0。父进程的 rax 保持不变(子进程 PID)。两个线程从同一个 fork 调用中返回,但因内核精心设置了不同的返回值。

设计层面:因为 0 在 UNIX 中永远是 “正常/成功” 的指示,并且 PID 不可能为 0(PID 1 是 init/systemd)。子进程自己不需要知道自己的 PID(需要时用 getpid()),它只需要知道 “我是子进程”。父进程需要知道子进程的 PID 以进行 waitpid

孤儿进程与僵尸进程

孤儿进程

当父进程先于子进程终止时,子进程成为孤儿进程。Linux 的处理方式是:内核将孤儿进程的父进程设置为 init 进程(PID 1, systemd)。此后:

孤儿进程是正常现象,不需要干预。一个进程可以在运行时故意让自己变成孤儿(先 fork,父进程退出,子进程继续运行),这是守护进程(daemon)的经典实现方式。

僵尸进程

当子进程先于父进程终止时,如果父进程没有调用 waitpid 回收子进程的退出状态,子进程会变成僵尸进程

关键理解:子进程终止时大部分资源被释放(物理内存、打开的文件、页表),但 task_struct(进程描述符)被保留,其中存储着子进程的退出码(exit_code)。内核需要保留这个信息,直到父进程通过 waitpid 取走它。

僵尸进程状态为 ZEXIT_ZOMBIE),ps 显示为 <defunct>。它不占用 CPU 和物理内存(除了一个 task_struct),但会占用 PID。如果大量僵尸进程堆积,可能耗尽 PID 命名空间(默认最多 32768 个 PID)。

# 检查是否有僵尸进程
ps aux | grep 'Z'

思考:为什么会产生僵尸进程?关键在”内核要保存退出码等父进程来取”。子进程死后,物理内存、文件、页表都释放了,但 task_struct 必须留着,因为里面存着退出码(exit_code),要等父进程 waitpid 拿走。父进程不 wait,这块”尸体”就一直挂在那占着 PID——这就是僵尸的由来。所以僵尸不是”泄漏内存”,而是”父进程忘了回收子进程的退出状态”。

如何避免僵尸进程

  1. 父进程调用 waitpid:阻塞等待子进程退出并回收状态
  2. 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN) — 子进程退出时内核自动回收,不产生僵尸进程
  3. 双重 fork:父进程 fork 一个子进程,子进程再次 fork 出孙进程后立即退出。孙进程成为孤儿由 init 接管。父进程 waitpid 回收的是立即退出的子进程,不会产生长期僵尸

fork 的性能考量

fork 本身的开销主要来自页表复制。对于一个 RSS 只有 10MB 但虚拟内存为 10GB 的进程(比如 mmap 了大量文件映射后又 munmap 掉了),由于页表仍需覆盖曾经映射过的虚拟地址范围,页表本身可能很大。fork 需要复制这些页表结构,即使是空的 PTE 也要遍历。

在 fork-heavy 场景(如 Apache prefork 模型、Redis 的 BGSAVE),可以考虑:

总结

fork 是现代 UNIX 进程创建的基础原语,它的精妙之处在于通过 Copy on Write 让”复制整个进程”从昂贵的全量拷贝变成了廉价的元数据复制。理解 fork 的全流程(页表复制 → CoW → 文件描述符共享 → 返回值设置 → 调度),以及孤儿/僵尸进程的状态机,是掌握进程管理的核心。记住房间里的大象:子进程返回 0 不是因为什么魔法,而是内核在子进程的内核栈上把 rax 硬编码成了 0。

章末提问

追问 1:fork 为什么能”一次调用、两次返回”?

回答思路:结论先行——因为 fork 在返回前已经把进程复制成两个,父子各带一份返回上下文。技术层:内核在父进程里让 fork 返回子进程 PID,在子进程的内核栈上把 rax 显式设成 0;设计层:0 既代表”成功”又与任何 PID 不冲突,子进程只需知道自己”是子进程”,真正要 PID 时用 getpid() 即可。

追问 2:fork 为什么用写时复制(COW)而不是直接复制内存?

回答思路:结论先行——因为 fork + exec 是主流模式,全量复制会被立即丢弃。子进程通常马上 exec 换掉整个内存镜像,若 fork 时就把父进程几百 MB 物理内存全拷一份,纯属浪费;COW 只复制页表、共享物理页、双方标只读,真正写入时才按页复制。用”按需增量复制”换掉”昂贵全量拷贝”,fork 才足够廉价。

追问 3:僵尸进程和孤儿进程有什么区别?各自怎么处理?

回答思路:结论先行——僵尸是”子先死、父不回收退出状态”,孤儿是”父先死、子还活着”。僵尸:子进程死后 task_struct 保留退出码等父 waitpid,父不 wait 就占着 PID;处理靠父调 waitpid、忽略 SIGCHLD 或双重 fork。孤儿:父先终止,内核把子进程交给 init(PID 1)接管,init 周期性 waitpid 回收,属正常现象,也是守护进程的经典实现方式。


Share this post on:

Previous Post
select→poll→epoll——Linux IO多路复用的三代演进
Next Post
epoll的LT和ET模式