进程怎么来的:fork / exec / wait 这套古老组合
这是 Linux 系列的第 15 篇,进入进程与并发章节。前面 09 篇讲了"怎么操作进程"——这一篇讲"进程怎么来的"。
0. 一个奇怪的设计
如果让你设计"启动一个新程序"的 API,最直觉的写法可能是:
spawn("ls", argv, envp); // 一步到位
但 Unix(1969 年)选了另一条路——两步:
pid = fork(); // 第 1 步:把自己原样复制一份
if (pid == 0) { // 子进程里
execve("ls", argv); // 第 2 步:副本"变身"成 ls
}
50 年后这条路还在用——所有 Linux 进程都是这么生出来的(包括你的 init / systemd / nginx / chrome)。
这一篇讲为什么这么设计、它给了我们什么、它的副产品有哪些。
1. fork():把自己复制一份
fork() 是个魔幻 syscall——它一次返回两次:
pid_t pid = fork();
// ↑↑↑↑↑
// 父进程里 pid = 子进程的 PID
// 子进程里 pid = 0
// 出错时 pid = -1
物理上发生了什么:
fork() 前:
┌────────────────────┐
│ 父进程 PID=12345 │
│ 内存:变量、stack │
│ 打开的文件 fd: ... │
└────────────────────┘
fork() 后:
┌────────────────────┐ ┌────────────────────┐
│ 父进程 PID=12345 │ │ 子进程 PID=12346 │
│ 内存:变量、stack │ ← │ 内存:完全一样 │
│ 打开的文件 fd: ... │ │ 打开的文件 fd: 同样 │
└────────────────────┘ └────────────────────┘
子进程从 fork() 后那行开始跑——继承父进程的:
- 内存(变量、栈、堆都一份复制)
- 打开的文件 / socket
- 当前工作目录
- 环境变量
- uid / gid
不继承的:
- PID(新 PID)
- 父 PID(PPID = 父进程的 PID)
- 大部分进程间锁
为什么"复制内存"不慢?COW 来救场
如果 fork 一个 8GB 的进程要复制 8GB——明显废。实际不复制:
Copy-on-Write(写时复制,COW):父子共享内存页,只在某一方写那一页时,内核才真的复制那一页。
父进程内存 子进程内存
┌─────┐ ┌─────┐
│ P │ ─ 都指向 ───→ │ 共享物理页(标记为 RO)│
│ P │ ─────────────→ │
└─────┘ └─────┘
如果父写第 1 页:
┌─────┐ ┌─────┐
│ P' │ ─→ 新页 P' │ P │ ─→ 旧页 P
│ P │ ─ 都指向 ─→ │ P │ ─→ 共享页
└─────┘ └─────┘
实际开销只是页表条目的复制——fork 一个 1GB 进程几毫秒搞定。
看你机器上的 PID 关系
$ ps -ef --forest
UID PID PPID CMD
root 1 0 /sbin/init
root 234 1 ├─ /lib/systemd/systemd-journald
root 456 1 ├─ /usr/sbin/sshd
root 5678 456 │ └─ sshd: alice [accepted]
alice 5680 5678 │ └─ -bash
alice 5701 5680 │ └─ vim file.py
www-data 891 1 └─ nginx: master process
www-data 892 891 └─ nginx: worker process
每个进程(除了 PID 1)都有爹。init / systemd 是 PID 1,是所有进程的祖先。
# 自己看
$ ps -o pid,ppid,comm -p $$
$ pstree -p $$
2. execve():让自己变成另一个程序
fork 复制出来的子进程跟父进程一模一样。要变成"另一个程序"——execve():
execve("/bin/ls", argv, envp);
// ↓ 这一行成功后,
// ↓ 当前进程的 PID 不变
// ↓ 但代码、数据全被 /bin/ls 覆盖
// ↓ execve 之后的代码永远不会执行
机制:
- 内核读 ELF 二进制
- 把当前进程的代码段 / 数据段 / 栈全部清空
- 加载新程序的代码 / 数据
- 重置 stack 跳到新程序的入口
- 已经打开的 fd / 环境变量 / PID 保留
注意"fd 保留"——这是 shell 重定向的根本机制(下面会讲)。
3. 为什么是两步?
把 fork 和 exec 分开,给了 shell 在中间动手脚的机会:
// shell 跑 `ls > out.txt` 时大概是这样:
pid_t pid = fork();
if (pid == 0) {
// 子进程
int fd = open("out.txt", O_WRONLY | O_CREAT);
dup2(fd, 1); // 把 stdout(fd 1)重定向到 out.txt
close(fd);
execve("/bin/ls", ...); // 现在 ls 跑起来,它的 stdout 已经被改了
}
// 父进程(shell)继续走
wait(NULL); // 等子进程结束
如果是一步式 spawn(),你没法在新进程跑起来之前修改它的 fd / 环境 / 工作目录。
这就是 Unix 神来一笔——把"新进程"和"复用执行环境"解耦,给中间留出空隙做配置。所有重定向、管道、setuid、chroot 都是利用这个空隙。
管道也是这么实现的
ls | grep foo 的 shell 内部大致:
int pipefd[2];
pipe(pipefd); // pipefd[0] 读端,pipefd[1] 写端
if (fork() == 0) { // 第一个子进程:跑 ls
dup2(pipefd[1], 1); // ls 的 stdout 接到管道写端
close(pipefd[0]);
close(pipefd[1]);
execve("/bin/ls", ...);
}
if (fork() == 0) { // 第二个子进程:跑 grep
dup2(pipefd[0], 0); // grep 的 stdin 接到管道读端
close(pipefd[0]);
close(pipefd[1]);
execve("/bin/grep", ["grep", "foo"]);
}
close(pipefd[0]);
close(pipefd[1]);
wait(NULL); wait(NULL);
一个 | 字符背后是 pipe + fork × 2 + dup2 × 2 + exec × 2。
4. 用 strace 亲眼看 shell 在干什么
$ strace -f -e fork,clone,execve,wait,dup2,close,pipe2 bash -c 'ls | head'
-f 是跟随 fork 出来的子进程。输出会很长,但你能逐句对照上面那段伪代码——哪一步 pipe2、哪一步 clone、哪一步 dup2、哪一步 execve。
现代 Linux 的 fork 内部走的是
clone()——传一组 flag 控制要不要共享内存 / fd / namespace 等。fork()其实是clone(0, ...)的特例。pthread 创建线程也是 clone(带 CLONE_VM 表示共享内存)。
5. wait() 和 zombie 的故事
子进程退出后,它的状态信息(退出码、CPU 时间)还留在内核里——直到父进程调 wait() 把它"收尸"。
int status;
pid_t pid = wait(&status);
// ↑
// 父进程阻塞,直到任一子进程退出
如果父进程没收尸——子进程变成 zombie(僵尸进程):
$ ps aux | grep ' Z '
USER PID ... STAT COMMAND
... 8742 ... Z [defunct]
状态 Z = zombie
内存几乎为 0(只剩一个 task_struct)
不能 kill(已经死了)
要让它消失:父进程 wait() 或者父进程死掉(孤儿被 init 收养,自动 reap)
Zombie 出现的常见场景
- 应用 bug:fork 子进程不 wait,子进程退出后留下尸体
- PID 1 不能 reap:早期 Docker 容器里跑某个 app 当 PID 1,子进程退出后没人 wait。需要用
tini之类的 init 程序
Docker 里跑 sh 之类的 init:
$ docker run --init -it myimage # --init 让 docker 自动加一个 tini 作 PID 1
孤儿进程:父亲先死了
父进程没 wait 就先死了的话,子进程被 init(PID 1)收养:
父进程死前 父死后
父 → 子 init(1) → 子 ← init 接管,会定期 wait 收尸
所以孤儿不可怕,zombie 才可怕(孤儿被 init 自动 reap)。
6. 几个常见的"奇怪现象"被解释了
A. nohup 怎么让进程脱离终端
nohup cmd & 实际:
shell:
fork → exec(nohup) → nohup setup → exec(cmd)
nohup 做的事:
- 忽略 SIGHUP
- stdout/stderr 重定向到 nohup.out
- 然后 exec 成你的 cmd
跟 fork/exec 分两步无关——nohup 就是中间动了下手脚再 exec。
B. 为什么 cd 不能是外部程序
$ which cd
cd: shell built-in command
如果 cd 是 /bin/cd,shell 就要 fork + exec 它。但子进程改了 cwd,父 shell 的 cwd 不受影响(每个进程独立工作目录)。
cd 必须是 shell 内建(builtin),直接在 shell 进程里调 chdir() 改自己。
C. 环境变量 export 的意义
$ FOO=bar # 只 shell 进程有 FOO
$ env | grep FOO # 没有!env 是 fork+exec 的,没继承 FOO
$ export FOO=bar # 让 FOO 进入"将来 fork 出去的子进程"的环境
$ env | grep FOO # 有了
FOO=bar
export 的意思是"把这个变量标记为'传给子进程'"——下一次 fork 时它会出现在子进程的 envp。
D. exec 命令(shell 内建)
$ exec node app.js
shell 不 fork,直接 execve("node", ...) 替换自己。原来的 shell 进程变身成 node——所以 exec 后那行没了。
应用场景:
- 容器 entrypoint:
exec "$@"让传进来的 cmd 成为 PID 1(不留个 sh 当壳) - 脚本结尾用 exec 替换,省一个进程
7. daemon 化的经典 3 步
把进程变成"后台服务"(不跟终端绑定的 daemon)的经典模式:
1. fork(),父退出 → 子被 init 接管(脱离终端 session)
2. setsid() → 自己变成新 session leader,彻底切断 tty
3. 再 fork() 一次 → 防止意外再获得控制终端
4. chdir("/") + 关闭 stdin/stdout/stderr → 别占着 fd
这就是早年 Apache / Nginx 的 daemon 化代码——20 行 C。现代 systemd 出来后不用手动 daemon 化了:直接前台跑,让 systemd 来管。
8. 一个能跑的小实验
# 1. 看 shell 跑命令时 fork 出去的进程
$ strace -f -e fork,clone,execve bash -c 'echo hi' 2>&1 | tail -10
# 2. 手动 fork:用 python 演示
$ python3 -c '
import os
pid = os.fork()
if pid == 0:
print(f"child PID={os.getpid()}, PPID={os.getppid()}")
else:
print(f"parent PID={os.getpid()}, child={pid}")
os.waitpid(pid, 0)
'
# 3. 制造一个 zombie(父进程不 wait)
$ python3 -c '
import os, time
pid = os.fork()
if pid == 0:
exit(0) # 子立刻退出
else:
print(f"child {pid} should be zombie now")
time.sleep(30) # 父睡着不收尸
' &
$ sleep 1 && ps aux | grep ' Z '
# 应该能看到一个 [python] <defunct>
# 30 秒后父退出,init 自动收尸,zombie 消失
9. 几个高频"伪面试题"的真答案
Q:fork 一次能复制 1GB 内存吗?很慢吧? A:不慢。COW 让初始几乎零拷贝,只在父子任一方真写某页时才复制那一页。
Q:fork 返回两次,怎么做到的? A:内核里 fork 实现一次,但让子进程从父进程相同的指令地址继续执行。父子的 rax 寄存器各自被设置成不同的返回值。
Q:为什么 Docker 容器里 PID 1 那么特殊? A:PID 1 不处理 SIGCHLD 的话,子进程退出后 zombie 不会被 reap;PID 1 死了整个容器就退出。所以容器 entrypoint 要么是个真正的 init(tini / dumb-init),要么自己处理 SIGCHLD。
Q:thread 和 process 在 Linux 里的区别?
A:内核眼里都是 task_struct。区别只是 clone 时传不同的 flag——thread 共享内存(CLONE_VM),process 不共享。
10. 现在做一件事
# 1. 看你 shell 的进程树
$ pstree -p $$
# 2. 用 strace 看 ls 启动的全过程
$ strace -fc ls > /dev/null
# 看哪些 syscall 出现得最多
# 3. 故意制造一个孤儿(不是 zombie)
$ ( sleep 30 & ) # 子 shell 跑完立刻退出,留 sleep 给 init
$ ps -o pid,ppid,comm -C sleep
# PPID 会是 1(被 init 收养)
# 4. 看你机器现在有没有 zombie
$ ps aux | awk '$8 ~ /Z/'
理解 fork / exec / wait——你看任何系统调用图 / Docker 文档 / shell 实现都会顺手很多。
下一篇:signals——
SIGINT / SIGTERM / SIGKILL / SIGHUP / SIGCHLD这堆信号到底怎么传,进程怎么 catch,Ctrl+C 按下去机器内部发生了什么。