核心 02-进程线程与调度 预计 25 分钟 kp-007

进程创建三件套:fork、exec 与写时复制

UNIX 用三个系统调用构成进程生命周期:`fork` 复制当前进程得到子进程,`exec` 用新程序替换进程映像,`wait` 让父进程回收子进程的退出状态;写时复制(COW)让 fork 的"复制"代价降到近乎为零。

学习状态:

一句话定义

UNIX 用三个系统调用构成进程生命周期:fork 复制当前进程得到子进程,exec 用新程序替换进程映像,wait 让父进程回收子进程的退出状态;写时复制(COW)让 fork 的"复制"代价降到近乎为零。

为什么重要

Shell 的每一条命令、Web 服务器 prefork 模型、CI 的每次任务派生,都走这条路。fork/exec 分离的设计让"复制自己再换身程序"成为可能,也解释了大量经典 bug:缓冲区双写、僵尸堆积、fork 后死锁。COW 更是贯穿内存管理(kp-017、kp-018)与文件系统快照(kp-025)的通用思想。

前置知识

kp-005(进程与 PCB);kp-003(系统调用机制)。

核心概念

  • fork():调用一次、返回两次——父进程中返回子进程 PID,子进程中返回 0。
  • exec 族(execve 及其包装):用磁盘上的新程序替换当前进程的代码、数据、堆栈,PID 不变。
  • wait()/waitpid():父进程阻塞收取子进程退出码,内核随后才释放子进程 PCB(消除僵尸)。
  • COW(Copy-On-Write):fork 时不拷贝物理内存页,父子共享页面并标记只读,任何一方写入时触发缺页、内核才复制那一页。

原理与机制

fork 的现代实现:复制 PCB(task_struct)与页表,把两边的页表项都标为只读并引用同一物理页;此后任一方写页触发保护性缺页,内核复制该页、改写页表、恢复执行——只有被写的页付出真实拷贝成本。exec 则完全丢弃旧映像:加载 ELF、重建地址空间、把入口设到新程序 main。

一个完整的 Shell 派生模型(C):

#include <unistd.h>
#include <sys/wait.h>
pid_t pid = fork();
if (pid == 0) {                 /* 子进程 */
    execvp("ls", (char*[]){"ls", "-l", NULL});  /* 替换映像 */
    _exit(127);                 /* exec 失败才会走到这里 */
} else if (pid > 0) {           /* 父进程 */
    int status;
    waitpid(pid, &status, 0);   /* 回收,避免僵尸 */
} else { /* fork 失败:进程/内存上限 */ }

直观类比

fork 像复印一份完整的工作档案但只在页面上贴"写时再复印"的便签(COW)——真正动笔改哪页,才去复印哪页。exec 像在同一工位上换了个新人:工牌(PID)不变,全部工作内容替换。

实例或案例

可复现实验(Linux):

strace -f -e trace=clone,execve,wait4 sh -c 'ls'   # 观察派生链:现代 Linux 上 fork 由 clone 实现
echo $((1)) > /dev/null; ps -ef | grep [d]efunct   # 找僵尸进程(STAT 列 Z)

一个经典 bug:fork() 前若有未 flush 的 stdio 缓冲,父子进程会各输出一遍相同内容——因为缓冲区随地址空间被"共享复制"了。修复方法是 fork 前 fflush(NULL)。

常见误区

  • 认为 fork 会立即复制全部内存:现代实现靠 COW,复制成本与"实际被写的页"成正比。
  • 认为 fork 后父子完全独立:文件描述符默认共享同一打开文件表项,偏移量互相影响。
  • 子进程退出后不管不问:不 wait 就产生僵尸;父进程先退则子进程被 init/systemd 收养并代为 wait。

与其他知识点的关系

COW 依赖 kp-017 的分页与缺页机制;多线程程序中 fork 的安全性问题连接 kp-008;fork+exec 的开销对比 vfork/posix_spawn 涉及 kp-006。

延伸阅读

《UNIX 操作系统设计》第 7 章;《操作系统导论》进程 API 章节的 shell 案例推导。

自测题

  1. fork 为什么能"调用一次返回两次"?子进程从哪里继续执行?

答:内核复制出子进程并让其从 fork 的返回点继续,父进程返回子进程 PID、子进程返回 0,因此同一段代码产生两条执行路径。

  1. 写时复制如何把 fork 的开销从 O(全部内存) 降到很低?

答:只复制 PCB 与页表并共享物理页(标记只读),写入时经缺页中断按需复制,成本与实际写入量成正比。

  1. 什么时候必须 wait?

答:子进程退出后,父进程需要读取退出状态时;不 wait,子进程 PCB 一直保留,形成僵尸占用进程表项。

标签:#fork #exec #写时复制 #COW #僵尸进程