8.2 fork、exec 与 wait
进程上下文列出了 task 与共享资源。本篇把
fork、exec、exit 和waitpid串成一条生命周期,顺带解释 COW、zombie 与 orphan。
shell 启动命令需要完成三件事
调度室收到 grep error app.log。Unix shell 通常先创建 child,在 child 中安排重定向与管道,再把 child 的 program image 替换成 grep;parent 决定等待前台任务,还是记录后台 job 后继续读命令。
shell process
└─ fork -> child context
├─ dup2/close: 安排 stdin/stdout/stderr
└─ exec: 装入 grep
shell --waitpid--> 取得 termination status 并回收 childfork 负责“产生一条新的执行线”,exec 负责“让当前 process 运行另一个 program”,wait 负责“读取 child 的结束结果”。三个 API 分开,给 child 在 exec 前设置 fd、credentials、working directory 等留下空间。
fork 一次调用,在两个执行流返回
POSIX fork() 成功后:
- parent 得到 child PID;
- child 得到 0;
- 两者都从
fork调用之后继续; - 失败只在 parent 原执行流返回
-1并设置errno。
谁先运行没有保证。把输出顺序写进断言,会得到偶发失败。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t child = fork();
if (child < 0) {
perror("fork");
return 1;
}
if (child == 0) {
printf("child: pid=%ld parent=%ld\n",
(long)getpid(), (long)getppid());
return 7;
}
int status;
pid_t waited;
do {
waited = waitpid(child, &status, 0);
} while (waited < 0 && errno == EINTR);
if (waited < 0) {
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
printf("parent: child %ld exited with %d\n",
(long)child, WEXITSTATUS(status));
return WEXITSTATUS(status) == 7 ? 0 : 1;
}
if (WIFSIGNALED(status)) {
printf("parent: child terminated by signal %d\n", WTERMSIG(status));
}
return 1;
}这里的 child 从 main 返回,C runtime 最终执行正常进程退出流程。若 child 即将 exec 且 exec 失败,通常应调用 _exit,避免重复 flush parent 在 fork 前缓冲的 stdio,也避免运行只属于 parent 的 atexit handler。
child 继承的是资源语义,不是一份简单 memcpy
fork 后 parent 与 child 有不同 PID、独立 virtual address-space 语义和各自 pending signal 集。很多属性来自 parent:working directory、umask、resource limit、environment、signal disposition 等按 POSIX 规则继承。
file descriptor table 是复制出的 descriptor 集合,但对应 entry 常指向相同 open file description。因此 parent/child 对 regular file 的 read 可能推进同一个 file offset。若不想让 descriptor 进入后续 exec,应设置 close-on-exec;创建时使用 O_CLOEXEC、pipe2(O_CLOEXEC) 等原子选项能避免多线程中“open 后再 fcntl”的泄漏窗口。
mutex、condition variable 和用户内存的字节在 child 里也有复制后的逻辑状态。多线程 parent 中,只有调用 fork 的 thread 出现在 child;其他 thread 消失,却可能在快照里留下已锁住的 mutex。这是 multi-threaded fork 最危险的边界之一。
POSIX 因而限制 child 在 fork 与 exec 之间可安全调用的函数,通常只能依赖 async-signal-safe 操作。复杂程序更适合 posix_spawn,或使用平台提供的专门 process-launch API。
COW 延迟复制 writable private page
内核不必在 fork 时复制 parent 的全部 physical memory。对 private writable mapping,parent 与 child 可暂时指向相同 physical page,并让 PTE 阻止直接写入:
parent VPN --read-only COW--+
+--> physical page
child VPN --read-only COW--+一方写入时触发 protection fault。内核若确认 page 仍被共享,就分配新 frame、复制内容、更新写入方 PTE 后重试指令。另一方仍看到旧值。
并非“fork 后所有页都标成只读”。原本只读 code page 无需为 COW 改变,MAP_SHARED mapping 按共享语义继续共享,device mapping 与 huge page 还有各自规则。page-table structure 本身也要建立 child 视图,会消耗时间和内存。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
int value = 42;
if (fflush(NULL) == EOF) {
perror("fflush");
return 1;
}
pid_t child = fork();
if (child < 0) {
perror("fork");
return 1;
}
if (child == 0) {
value = 100;
printf("child value=%d address=%p\n", value, (void *)&value);
return 0;
}
int status;
while (waitpid(child, &status, 0) < 0) {
if (errno != EINTR) {
perror("waitpid");
return 1;
}
}
printf("parent value=%d address=%p\n", value, (void *)&value);
return value == 42 && WIFEXITED(status) && WEXITSTATUS(status) == 0
? 0 : 1;
}两个进程通常打印相同 virtual address、不同值。这证明它们有独立 address-space 语义;它本身不能观察 physical frame。要研究真实 page sharing,需要受权限控制的 OS instrumentation,并考虑 compiler、THP 与 kernel 版本。
exec 成功后不会返回
execve(path, argv, envp) 用新 executable 初始化当前 process image。成功后,旧代码不会继续,调用者也收不到“成功返回值”;新程序从它的 entry point 启动。失败才返回 -1。
process PID 保持不变,但“替换一切”也过头了。常见变化与保留包括:
| 项目 | exec 后 |
|---|---|
| virtual address mappings、user stack | 替换为新 image 与运行时布局 |
| PID、parent relationship | 保留 |
| open fd | 保留,除非设了 FD_CLOEXEC |
| current directory、umask | 保留 |
| caught signal disposition | 通常重置为默认 |
| ignored signal disposition | 通常保持忽略 |
| signal mask | 保留 |
| threads | 只留下调用 exec 的 thread |
| environment | 由 envp 或 exec* variant 决定 |
set-user-ID、capability、tracing、timer、shared-memory attachment 等还有额外规则。安全敏感程序必须查 execve(2),不能靠一张入门表覆盖。
execlp/execvp 会按 PATH 搜索,execve 使用明确路径并显式传入 environment。运行不可信环境时,应避免继承攻击者控制的 PATH、loader variable 与意外 fd。
在调度室走完 fork + exec + waitpid
现在让 shell 启动一条真实命令:parent 创建 child,child 整理 file descriptor 后替换 program image,parent 最后收取 termination status。三个阶段必须从输出和返回值上分别验证。
下面的程序启动 /bin/sh -c 'exit 7',parent 完整解码 status。真实应用若不需要 shell 语法,应直接 execv 目标程序,避免额外 parsing 与 command-injection 面。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int run_and_wait(char *const arguments[]) {
pid_t child = fork();
if (child < 0) {
perror("fork");
return -1;
}
if (child == 0) {
execv(arguments[0], arguments);
static const char message[] = "execv failed\n";
(void)write(STDERR_FILENO, message, sizeof message - 1);
_exit(127);
}
int status;
pid_t waited;
do {
waited = waitpid(child, &status, 0);
} while (waited < 0 && errno == EINTR);
if (waited < 0) {
perror("waitpid");
return -1;
}
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
if (WIFSIGNALED(status)) {
fprintf(stderr, "child terminated by signal %d\n",
WTERMSIG(status));
}
return -1;
}
int main(void) {
char *arguments[] = {"/bin/sh", "-c", "exit 7", NULL};
int result = run_and_wait(arguments);
printf("decoded exit status=%d\n", result);
return result == 7 ? 0 : 1;
}shell pipeline 会创建多个 child、连接 pipe endpoint、关闭每个进程不使用的 fd,并分别回收整个 job。遗漏一个写端 fd 就可能让 reader 永远等不到 EOF,这比 fork 本身更常见。
zombie 是尚未被 parent 收取的结束记录
child 结束时,address space、fd 等大部分运行资源被释放,kernel 保留 PID、termination status 和 accounting 供 parent wait。这段残留称为 zombie;它不执行指令,也不占用原来的用户内存,却会占用 process-table/PID 等有限资源。
parent 应:
- 对已知 child 调用
waitpid,正确处理EINTR; - 管理多个 child 时循环回收所有已结束对象;
- event loop 可结合
SIGCHLD、pidfd 或平台机制,但标准 signal 会合并,不能一次 handler 只 wait 一个 child; - 若明确不需要 status,可按平台/POSIX 语义配置
SIGCHLD/SA_NOCLDWAIT,并验证可移植性。
只安装一个空 SIGCHLD handler 不会自动回收 child。长期服务最稳妥的是让 child ownership 清晰,并把 reap 逻辑当作资源管理的一部分。
orphan 会交给 reaper,不保证新 parent 是 PID 1
parent 先结束时,仍存活的 child 会被 reparent。传统系统由 init(PID 1)接管;Linux subreaper、PID namespace 和 service manager 可以让另一个 ancestor 成为 reaper。因此示例里“5 秒后 PPID 必为 1”没有通用保证。
reparenting 解决的是谁负责最终 wait,不会把 daemon 自动配置正确。现代服务更适合由 systemd、container init 或 supervisor 明确管理 lifecycle、signal forwarding 与日志。
double-fork 曾用于脱离 terminal/session 并让 descendant 被 init 收养,但在 service manager 环境中往往不需要,反而让 supervisor 难以跟踪真实 worker。
exit status 不是一个普通整数返回值
waitpid 写入的 status 是编码结果,必须先判断:
WIFEXITED后才读WEXITSTATUS;WIFSIGNALED后可读WTERMSIG;- 使用
WUNTRACED/WCONTINUED时还可能观察 stop/continue; - shell 常把 signal termination 映射为
128 + signal,这是 shell convention,不是waitpid原始格式。
C main 返回值或 exit 参数最终只保留平台规定的 status 范围。把任意 32-bit 业务错误码塞进 process exit status 会丢信息,复杂结果应通过 pipe、file、socket 或 shared memory 返回。
动手走完生命周期
- 在第一个示例中加入随机 sleep,证明 parent/child 输出顺序不受 PID 大小保证。
- 用一个 file descriptor 演示 parent/child 共享 open-file offset,再用两次独立
open对比。 - 给
run_and_wait增加 exec failure 测试,区分约定的 127 与目标程序主动 exit 127 的歧义。 - 构造一个 child 被
SIGTERM结束的场景,验证不能直接调用WEXITSTATUS。 - 写两级 pipeline,列出 parent 与两个 child 各自必须关闭的 pipe endpoint。
- 在 multi-threaded parent 中说明另一个 thread 持锁时 fork 会给 child 留下什么状态,并评估
posix_spawn。
共享地址空间会把问题从复制变成同步
process 之间默认隔离,thread 则在同一 address space 中并发执行。下一章进入线程与同步:先从 C memory model 定义 data race,再讨论 mutex、condition variable 与 atomic,而不是把“看起来能跑”当作并发正确性。