2.7 进程创建与终止
fork() 之后,父子进程到底共享什么、复制什么?写时复制(COW)在什么时刻才真正复制物理页?exit() 与 _exit() 差在哪一步?僵尸进程占的是内存还是别的资源,孤儿又去了哪里?多线程程序里调用 fork() 为什么会死锁?
画出 fork() 后地址空间、fd 表、信号处置的继承关系;用 COW 解释 VmRSS 的变化;写出不重复 flush、不泄漏僵尸的父进程循环;用 waitpid() 的四个事件位正确区分退出、被杀、停止与继续。
到 2.6 为止,我们处理的对象都是「一个进程内部的文件」。从这一章开始,主角变成进程本身:它怎么被创建、怎么结束、结束后谁来收尸。
Unix 的进程模型只有两个原语:fork() 复制当前进程,execve() 用新程序替换当前进程的地址空间(2.8)。所有 shell、服务管理器、容器运行时,都是这两条原语加 wait() 的组合。理解它们的语义边界,比记住参数列表重要得多:fork() 的「复制」是逻辑上的,物理内存靠 COW 延迟分配;exit() 的「结束」也不是一瞬间,进程会先变成僵尸,等父进程确认后才真正消失。
这一章的主线是「创建与销毁都是协议,不是函数调用」。父子之间有继承规则(fd、信号、凭证)、有通知机制(SIGCHLD)、有回收义务(wait)。任何一环漏掉,症状都不是报错,而是资源慢慢泄漏:僵尸堆积、fd 泄漏、内存翻倍。
2.7.1 fork() 的返回值与继承清单
pid_t fork(void) 创建一个新进程。调用一次,返回两次:
- 父进程中返回子进程 PID(
> 0); - 子进程中返回 0;
- 失败时父进程返回
-1,errno为EAGAIN(进程数或RLIMIT_NPROC超限)、ENOMEM(内核内存不足)或ENOSYS(平台不支持)。
子进程是父进程的逻辑副本:地址空间按 COW 共享物理页,fd 表被复制但表项指向同一个打开文件描述,信号处置被继承,未决信号被清空。
最小可用的创建与回收:
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子进程:这里只能看到自己 */
_exit(0);
}
/* 父进程:pid 是子进程号 */
int status;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
exit(EXIT_FAILURE);
}
fork() 之后,父子是两个独立的调度实体,谁先运行由调度器决定。不要假设子进程先跑或父进程先跑:这是 2.7.6 里竞争条件的根源,也是 shell 管道实现必须小心的地方。
继承清单
「复制」这个词太粗。内核在 copy_process() 里逐项决定:哪些复制、哪些共享、哪些清零。
| 对象 | fork() 后 | 说明 |
|---|---|---|
| 地址空间 | COW 共享物理页 | 页表复制,PTE 只读;写时复制 |
| fd 表 | 复制,表项指向同一 struct file | 共享偏移与文件状态标志;FD_CLOEXEC 位也被复制 |
文件锁(fcntl 记录锁) | 不继承 | 子进程不持有父进程的记录锁 |
flock() 锁 | 继承(共享同一 open file description) | 与记录锁语义不同,见 2.3 |
| 信号处置 | 继承 | SIG_IGN/处理器地址都复制 |
| 未决信号 | 清空 | 子进程从空信号集开始 |
| 信号掩码 | 继承 | sigprocmask 的设置跟着走 |
| 定时器 | 不继承 | alarm/setitimer 在子进程中清零 |
| 进程组、会话 | 继承 | 子进程在同一进程组;setsid 要自己调 |
| 资源限制 | 继承 | RLIMIT_* 复制 |
| 凭证 | 继承 | UID/GID 四件套与 capability 都复制(2.9) |
umask、cwd、chroot | 继承 | 都复制 |
内存映射 MAP_SHARED | 共享同一物理页 | 子进程写入父进程可见,不触发 COW |
这张表解释了三类高频 bug:子进程「莫名其妙」写到了父进程的共享映射;父进程的 alarm 在子进程里消失导致超时不生效;子进程继承了大量没关的 fd,把下游程序的输入卡住。
- 「
fork会把父进程内存复制一份」——逻辑上复制,物理上共享。只有某一方写入的页才复制,见 2.7.2。 - 「父子进程的 fd 是各自独立的」——fd 表独立,但表项指向同一个
struct file,文件偏移共享。父进程读 100 字节,子进程的read从 101 开始。 - 「
fork后子进程会收到父进程未处理的信号」——未决信号被清空。父进程阻塞着的SIGUSR1不会传给子进程。 - 「
fork会继承父进程的alarm」——不会。定时器在子进程中清零,这是SIGALRM超时逻辑在 fork 后失效的原因。 - 「
fork失败只会因为内存不够」——EAGAIN更常见:RLIMIT_NPROC、cgrouppids.max、全局kernel.pid_max都会触发。
2.7.2 写时复制:页表、引用计数与断裂点
fork() 最容易被误解的地方是「复制」。如果真复制,一个 8 GiB 的进程 fork 一次就要 8 GiB 空闲内存,shell 早就不可用了。实际做法是:
- 内核为子进程复制页表(不是数据页),把所有私有可写 PTE 改成只读;
- 每个物理页的引用计数加一;
- 任一方写入只读页时触发缺页异常,内核判断是 COW 缺页,分配新页、复制内容、改 PTE 为可写,并把原页引用计数减一。
fork 前 fork 后(未写) 子进程写入后
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 父页表 │ │ 父页表 │ │ 子页表 │ │ 父页表 │ │ 子页表 │
│ PTE 可写 │ │ PTE 只读 │ │ PTE 只读 │ │ PTE 可写 │ │ PTE 可写 │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ └──────┬──────┘ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 物理页 A │ │ 物理页 A │ │ 物理页 A │ │ 物理页 B │
│ refcount=1│ │ refcount=2│ │ refcount=1│ │ refcount=1│
└──────────┘ └──────────┘ └──────────┘ └──────────┘
写保护,共享 原页留给父进程 新页复制给子进程
读图:COW 的关键不是「复制」,而是「先共享、后分裂」。fork 的代价从「拷贝全部数据」降到「拷贝页表 + 增加引用计数」,页表项数量与虚拟内存大小成正比,而不是与驻留集成正比。写时复制发生在第一次写,而且以页为单位(通常 4 KiB):一个页里只要有一个字节被改,整页复制。
fork() 之后紧接着 execve() 是最常见的组合。子进程在 exec 之前通常只改几十个页(栈、少量全局变量),exec 一到,整个地址空间被丢弃。如果 fork 真的复制全部内存,这些复制全是白费。COW 让「fork + exec」的成本约等于「复制页表 + 加载新程序」。
设父子进程在 fork() 时映射了同一段内存:
- 若映射是
MAP_PRIVATE(包括普通的堆、栈、.data),双方的写入互不可见,各自触发 COW; - 若映射是
MAP_SHARED(包括mmap(MAP_SHARED|MAP_ANONYMOUS)与共享文件映射),不触发 COW,双方看到同一物理页,写入立刻互相可见,同步要自己做(2.15); - 通过
read/write访问的文件内容不属于地址空间,共享与否由 fd 决定,与 COW 无关。
下面的程序把这三条规则变成可观测的数字:分配 256 MiB 并逐页触碰,fork 后分别打印 VmRSS,再让子进程每页写一个字节,观察 RSS 的变化。
/* cow_probe.c:观察 fork 的 COW 与 MAP_SHARED 的可见性 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
#define REGION_MB 256
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
/* 读 /proc/self/status 的 VmRSS;非 Linux 返回 -1 */
static long rss_kb(void)
{
FILE *fp = fopen("/proc/self/status", "r");
if (fp == NULL)
return -1;
char line[256];
long kb = -1;
while (fgets(line, sizeof line, fp) != NULL) {
if (strncmp(line, "VmRSS:", 6) == 0) {
if (sscanf(line + 6, "%ld", &kb) != 1)
kb = -1;
break;
}
}
fclose(fp);
return kb;
}
int main(void)
{
size_t len = (size_t)REGION_MB << 20;
char *priv = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (priv == MAP_FAILED)
die("mmap private");
/* 逐页触碰,确保每一页都真正分配了物理页 */
for (size_t i = 0; i < len; i += 4096)
priv[i] = 1;
int *shared = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (shared == MAP_FAILED)
die("mmap shared");
*shared = 1000;
printf("parent before fork : rss=%ld kB shared=%d\n", rss_kb(), *shared);
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
printf("child after fork : rss=%ld kB shared=%d\n", rss_kb(), *shared);
/* 写共享页:不触发 COW,父进程应看到 2000 */
*shared = 2000;
/* 每页写一个字节:每一页都触发一次 COW 复制 */
for (size_t i = 0; i < len; i += 4096)
priv[i] = 2;
printf("child after write : rss=%ld kB shared=%d\n", rss_kb(), *shared);
_exit(0);
}
int status;
if (waitpid(pid, &status, 0) == -1)
die("waitpid");
/* 父进程的私有页没有被复制,RSS 不变;共享页已变 */
printf("parent after child : rss=%ld kB shared=%d\n", rss_kb(), *shared);
printf("child exit : %s %d\n",
WIFEXITED(status) ? "exited" : "other", WEXITSTATUS(status));
munmap(priv, len);
munmap(shared, 4096);
return EXIT_SUCCESS;
}
编译与运行(Linux):
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g cow_probe.c -o cow_probe
./cow_probe
预期输出(数值随内核与内存压力浮动):
parent before fork : rss=257012 kB shared=1000
child after fork : rss=257012 kB shared=1000
child after write : rss=514180 kB shared=2000
parent after child : rss=257012 kB shared=2000
关键在第二行:fork 刚完成时子进程 RSS 与父进程几乎相同,因为页是共享的,VmRSS 统计的是映射到该进程的驻留页(共享页会计入每个进程)。写完之后子进程 RSS 翻倍,说明 256 MiB 被逐页复制。
VmRSS 是「该进程页表里映射的、当前驻留在内存中的页」的计数,不区分独占还是共享。fork 后子进程页表指向同一批物理页,这些页对子进程也是驻留的,所以 RSS 立即等于父进程。真正能看出 COW 的是写入后的增量,以及 /proc/meminfo 里 AnonPages 的变化——但后者受全局回收影响,不如 RSS 差值直观。
2.7.3 多线程程序中的 fork():为什么只允许 async-signal-safe 调用
fork() 只复制调用线程,其他线程在子进程中不存在。但其他线程持有的锁、正在写的缓冲区、正在更新的数据结构,都以「被复制的一份」留在子进程内存里。子进程如果去拿那把锁,就永远等不到释放者——因为持有者线程已经不存在了。
POSIX 规定:在多线程程序中,fork() 之后、execve() 之前,子进程只能调用 async-signal-safe 函数(见 2.10 的列表)。malloc()、printf()、pthread_mutex_lock()、syslog() 都不在其中。原因是这些函数可能依赖被其他线程持有的内部锁。
最典型的死锁场景:
- 线程 A 调用
malloc(),拿到 glibc 的arena锁,尚未释放; - 线程 B 调用
fork(),子进程复制了「锁已被持有」的内存状态; - 子进程调用
malloc()(哪怕只是printf内部),发现锁被占,永久阻塞。
这个窗口很窄,但在高并发服务里会稳定出现,而且 gdb 附加进去只能看到子进程卡在 __lll_lock_wait,看不出所以然。
正确的三种写法
写法一:fork 后只做 exec。 子进程只调用 execve(以及 _exit),不碰任何库函数:
pid_t pid = fork();
if (pid == 0) {
/* 只允许 async-signal-safe:execve 与 _exit 都在列表里 */
execve("/bin/true", (char *const[]){ "true", NULL }, environ);
_exit(127); /* exec 失败:用 _exit,不 flush、不跑 atexit */
}
写法二:在 fork 前把状态收干净。 例如先 fflush(NULL)、暂停其他线程、确保没有线程持锁。这需要应用自己设计,glibc 无法代劳。
写法三:用 pthread_atfork() 注册处理器。 它在 fork 前后提供三个钩子:
#include <pthread.h>
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static void prepare(void) { pthread_mutex_lock(&lock); } /* fork 前,父进程 */
static void parent_hook(void) { pthread_mutex_unlock(&lock); } /* fork 后,父进程 */
static void child_hook(void) { pthread_mutex_unlock(&lock); } /* fork 后,子进程 */
int register_hooks(void)
{
return pthread_atfork(prepare, parent_hook, child_hook);
}
prepare 在 fork 之前按注册的逆序执行,parent/child 在 fork 之后按正序执行。它只能解决你自己知道的锁;glibc malloc、stdio、DNS 解析器内部的锁不在你的控制范围内。所以 pthread_atfork 是缓解手段,不是许可证。
- 「子进程会继承所有线程」——只复制调用
fork()的那个线程,其他线程消失。子进程的gettid()与父进程调用者不同。 - 「
fork后调用printf没事,我试过」——没事是概率事件。锁恰好没被持有时能跑通,压力下必现。 - 「
pthread_atfork能解决所有死锁」——只能处理你注册的锁。malloc的 arena 锁、stdio的FILE锁、NSS 的锁都不归你管。 - 「glibc 2.34 的
_Fork()没有这个问题」——_Fork()跳过 atfork 处理器、不运行fork的兼容逻辑,语义更接近裸系统调用,但锁状态依然被复制,限制并没有消失。
工程上的结论:多线程程序不要 fork 出「继续跑业务逻辑」的子进程。要并行就用线程或线程池(2.12、2.13);要执行外部程序就用 posix_spawn()(2.7.4)或先 fork 再立刻 exec。
2.7.4 vfork()、clone() 与 posix_spawn()
vfork() 是 fork 的历史优化:子进程共享父进程的地址空间,父进程被挂起,直到子进程 exec 或退出。
pid_t pid = vfork();
if (pid == 0) {
/* 不能修改任何变量,不能 return,不能调用 exit */
execl("/bin/ls", "ls", "-l", (char *)NULL);
_exit(127);
}
它的规则比 fork 更苛刻:子进程修改栈上的变量会破坏父进程;return 会破坏调用栈;exit() 会 flush 父进程的 stdio 缓冲区。vfork() 的唯一正当用途是「立刻 exec」,而这个用途已经被 posix_spawn() 覆盖。
clone() 是 fork 的底层实现,通过 flags 决定共享哪些资源:
| flag | 效果 | 用途 |
|---|---|---|
CLONE_VM | 共享地址空间 | 线程 |
CLONE_FILES | 共享 fd 表 | 线程 |
CLONE_FS | 共享 cwd/umask/chroot | 线程 |
CLONE_SIGHAND | 共享信号处置 | 线程(必须与 CLONE_VM 同用) |
CLONE_THREAD | 放入同一线程组 | pthread |
CLONE_NEWPID | 新 PID 命名空间 | 容器 |
CLONE_NEWNS | 新挂载命名空间 | 容器 |
CLONE_PARENT | 子进程的父进程是调用者的父进程 | 特殊场景 |
pthread_create() 在 Linux 上就是 clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|...)。所以「线程」和「进程」在 Linux 里不是两个东西,而是同一套创建接口的不同 flag 组合(第 3 篇会从内核侧展开)。
posix_spawn() 把「fork + exec」封装成一个调用,并允许在两者之间做有限的文件操作:
#include <fcntl.h>
#include <spawn.h>
#include <string.h>
extern char **environ;
posix_spawn_file_actions_t fa;
posix_spawn_file_actions_init(&fa);
/* 子进程里:把 stdout 重定向到文件,关闭一个不需要的 fd */
posix_spawn_file_actions_addopen(&fa, STDOUT_FILENO, "out.log",
O_WRONLY | O_CREAT | O_TRUNC, 0644);
posix_spawn_file_actions_addclose(&fa, 3);
pid_t pid;
int rc = posix_spawn(&pid, "/bin/echo",
&fa, NULL,
(char *const[]){ "echo", "hello", NULL }, environ);
posix_spawn_file_actions_destroy(&fa);
if (rc != 0) { /* 注意:返回错误码,不设置 errno */
fprintf(stderr, "posix_spawn: %s\n", strerror(rc));
return EXIT_FAILURE;
}
两个必须记住的细节:posix_spawn() 成功返回 0,失败返回错误码(不是 -1/errno);它无法表达「子进程需要调用任意函数」的需求,posix_spawnattr 只支持有限的标志(信号掩码、调度策略、POSIX_SPAWN_SETSID 等)。glibc 在部分场景会用 clone(CLONE_VM|CLONE_VFORK) 实现它,比手写 fork+exec 少一次页表复制。
- 执行外部程序、不需要复杂父子通信:
posix_spawn(); - 需要精细控制子进程(改 uid、设 rlimit、进命名空间):
fork()+ 在子进程里调async-signal-safe函数 +execve(); - 需要「子进程继续跑一段自己的代码」:线程,不是进程。
2.7.5 进程终止:exit()、_exit() 与退出处理程序
void exit(int status) 是库函数,它按顺序做四件事:
- 按注册的逆序调用
atexit()/on_exit()注册的函数; - flush 并关闭所有打开的 stdio 流;
- 删除
tmpfile()创建的临时文件; - 调用
_exit(status)。
void _exit(int status) 是系统调用,直接进入内核:关闭所有 fd、释放内存与锁、把退出状态留给父进程,然后变成僵尸。它不 flush stdio、不运行 atexit。
退出状态只有低 8 位能传给父进程:exit(256) 在父进程看来是 0。shell 约定 128 + 信号编号 表示「被信号杀死」,但这是 shell 的约定,不是内核的:waitpid 的 status 里,被信号杀死时 WIFSIGNALED 为真,WTERMSIG 给出信号号,退出码字段根本不存在。
经典 bug:fork 后 stdio 缓冲区被复制
printf 写到终端时是行缓冲,写到文件时是全缓冲。如果 fork 之前缓冲区里还有未 flush 的数据,子进程会复制整个缓冲区;父子各自 exit() 时各 flush 一次,输出就重复了。
下面这个程序把差异做成开关:
/* exit_vs_exit.c:演示 fork 后 stdio 缓冲区被复制 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static void on_exit_handler(void)
{
/* 故意用 printf:它写进同一个 stdio 缓冲区 */
printf("[atexit] pid=%ld runs handler\n", (long)getpid());
}
int main(int argc, char **argv)
{
int use_exit = (argc > 1 && strcmp(argv[1], "exit") == 0);
if (atexit(on_exit_handler) != 0) {
fprintf(stderr, "atexit failed\n");
return EXIT_FAILURE;
}
/* 没有换行:数据留在 stdio 缓冲区里,不立即 write */
printf("buffered-before-fork");
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0) {
if (use_exit)
exit(0); /* flush 复制来的缓冲区 + 跑 atexit */
_exit(0); /* 直接进内核,什么都不做 */
}
int status;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
return EXIT_FAILURE;
}
printf("\nchild done, exit=%d\n", WIFEXITED(status) ? WEXITSTATUS(status) : -1);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g exit_vs_exit.c -o exit_vs_exit
./exit_vs_exit _exit > out1.txt; ./exit_vs_exit exit > out2.txt
$ ./exit_vs_exit _exit > out1.txt ; cat out1.txt
buffered-before-fork
child done, exit=0
[atexit] pid=48210 runs handler
$ ./exit_vs_exit exit > out2.txt ; cat out2.txt
buffered-before-fork[atexit] pid=48213 runs handler
buffered-before-fork
child done, exit=0
[atexit] pid=48212 runs handler
两次运行都有父进程的那行 [atexit] pid=48210/48212,这是正常的:父进程本来就要走 exit()。差别在子进程:out2.txt 里多出的 pid=48213 是子进程,它先跑 atexit、再把「fork 时复制来的缓冲区」flush 出来,于是 buffered-before-fork 出现了两次;out1.txt 里只有一次,因为子进程 _exit() 什么都没做。
修复方式只有两个:fork 之前 fflush(NULL),或者子进程一律 _exit()。库函数与框架代码应默认 _exit(),把 flush 的责任留给明确知道自己在做什么的调用者。
- 「
exit(1)和return 1在main里不同」——在main里等价(C 标准保证return会走exit)。在其他函数里return只是返回,不终止进程。 - 「
exit()会立即停止所有线程」——exit()会终止整个进程(包括其他线程),但在此之前会跑 atexit 与 flush。_exit()跳过这些步骤。 - 「
atexit注册的函数按注册顺序执行」——逆序(LIFO)。先注册的后执行,适合「后申请的资源先释放」。 - 「退出码可以任意大」——只有低 8 位有效。
exit(1000)父进程看到的是 232。
2.7.6 僵尸、孤儿与 wait 家族
进程终止后并不会立刻消失。内核要保留它的退出状态、资源用量,等父进程来取。这段时间里进程处于 TASK_ZOMBIE 状态。
读图:TASK_ZOMBIE 是「已退出、等 wait」,它占的是 task_struct 与少量记账信息,不是内存页。图中从 TASK_RUNNING 有一条 exit() 指向 ZOMBIE,再从 ZOMBIE 经 wait() 到 EXIT_DEAD;只有走完这条线,PID 才会被回收。TASK_TRACED 是另一条支路:被 ptrace 暂停的进程不响应普通信号,调试器退出时要负责让它继续或终止。
- 僵尸(zombie):已终止但父进程尚未
wait的进程。ps中状态为Z,仍占用 PID 与task_struct。 - 孤儿(orphan):父进程先于它终止的进程。内核把它过继给
init(PID 1)或最近的subreaper,由后者负责回收。
僵尸不能用 kill -9 消灭——它已经死了。唯一的办法是父进程调用 wait 家族,或者父进程自己退出(僵尸被过继给 init 后回收)。如果父进程长期不回收,PID 会被耗尽:fork 开始返回 EAGAIN,系统看起来「进程数没满但创建不了进程」。
/* reap_children.c:用 waitpid 处理退出、被杀、停止与继续 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#define NCHILD 4
int main(void)
{
for (int i = 0; i < NCHILD; i++) {
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0) {
switch (i) {
case 0:
_exit(0); /* 正常退出,码 0 */
case 1:
_exit(7); /* 正常退出,码 7 */
case 2:
raise(SIGTERM); /* 被信号杀死 */
_exit(1); /* 不会到这里 */
default:
raise(SIGSTOP); /* 先停住,等父进程放行 */
_exit(3); /* SIGCONT 之后退出 */
}
}
}
int alive = NCHILD;
int status;
while (alive > 0) {
pid_t pid = waitpid(-1, &status, WUNTRACED | WCONTINUED);
if (pid == -1) {
if (errno == EINTR)
continue;
if (errno == ECHILD)
break; /* 没有子进程了 */
perror("waitpid");
return EXIT_FAILURE;
}
if (WIFEXITED(status)) {
printf("pid=%ld exited code=%d\n", (long)pid, WEXITSTATUS(status));
alive--;
} else if (WIFSIGNALED(status)) {
printf("pid=%ld signaled sig=%d%s\n", (long)pid, WTERMSIG(status),
WCOREDUMP(status) ? " (core dumped)" : "");
alive--;
} else if (WIFSTOPPED(status)) {
printf("pid=%ld stopped sig=%d -> SIGCONT\n", (long)pid, WSTOPSIG(status));
if (kill(pid, SIGCONT) == -1) {
perror("kill SIGCONT");
return EXIT_FAILURE;
}
} else if (WIFCONTINUED(status)) {
printf("pid=%ld continued\n", (long)pid);
}
}
printf("all %d children reaped\n", NCHILD);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g reap_children.c -o reap_children
./reap_children
输出顺序不固定,但四类事件都会出现:两个 exited、一个 signaled、一个 stopped + continued + exited。核心在 waitpid 的第一个参数 -1(等待任意子进程)与 WUNTRACED | WCONTINUED:不加这两个标志,停止与继续事件不会返回,父进程会以为子进程「还没结束」。
等待的四种接口
| 接口 | 能等到什么 | 额外信息 | 典型用途 |
|---|---|---|---|
wait(&status) | 任一子进程退出 | 无 | 只有一个子进程 |
waitpid(pid, &st, flags) | 指定/任一组子进程 | 退出码、信号、停止 | 常规回收 |
waitid(idtype, id, &si, flags) | 同上,粒度更细 | siginfo_t:si_code、si_uid、si_status | 需要区分「谁杀的」 |
wait3/wait4 | 任一子进程 | 额外 struct rusage | 资源统计(2.8) |
waitid 的 siginfo_t 比 status 字更清楚:
siginfo_t si;
if (waitid(P_PID, pid, &si, WEXITED | WNOHANG) == -1) { /* 错误 */ }
if (si.si_pid == 0) {
/* WNOHANG:没有可回收的子进程 */
} else if (si.si_code == CLD_EXITED) {
/* 正常退出,码在 si_status */
} else if (si.si_code == CLD_KILLED || si.si_code == CLD_DUMPED) {
/* 被信号杀死,信号号在 si_status */
} else if (si.si_code == CLD_STOPPED) {
/* 停止,信号号在 si_status */
}
注意 si.si_pid == 0 才是「没有事件」,不是 -1。waitid 在 WNOHANG 且无子进程可等时返回 0,这是它与 waitpid 最容易混的地方。
SIGCHLD 的坑
用信号驱动回收很自然:注册 SIGCHLD 处理器,在处理器里 while (waitpid(-1, &st, WNOHANG) > 0)。但有两个坑:
- 信号会合并。三个子进程几乎同时退出,标准信号只记一次未决,处理器可能只被调用一次。所以处理器里必须用
while循环把已退出的子进程全部回收,不能只waitpid一次。 WNOHANG循环必须在返回 0 或 -1 时退出。循环条件用> 0,不要写成死循环;子进程还没退出时waitpid会返回 0。
如果完全不想管僵尸,可以在创建子进程之前把 SIGCHLD 设为 SIG_IGN,或给 sigaction 加 SA_NOCLDWAIT:
struct sigaction sa = {0};
sa.sa_handler = SIG_IGN;
sa.sa_flags = SA_NOCLDWAIT; /* 子进程退出时直接回收,不产生僵尸 */
sigemptyset(&sa.sa_mask);
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
代价是再也拿不到退出状态:waitpid 返回 ECHILD。只适合「fire-and-forget」的子进程。
父进程先死:PR_SET_PDEATHSIG
服务常需要「父进程崩溃时子进程跟着退出」。Linux 提供 prctl(PR_SET_PDEATHSIG, sig):
#ifdef __linux__
#include <sys/prctl.h>
static void setup_parent_death_signal(pid_t expected_parent)
{
if (prctl(PR_SET_PDEATHSIG, SIGTERM) == -1) {
perror("prctl");
exit(EXIT_FAILURE);
}
/* 竞态:父进程可能在 prctl 之前就死了,信号不会补发 */
if (getppid() != expected_parent)
_exit(1);
}
#endif
顺序不能反:先 prctl,再检查 getppid()。如果父进程在两次调用之间退出,getppid() 会变成 1(或 subreaper),检查失败直接退出;如果先检查再 prctl,父进程恰好死在中间,子进程就永远收不到信号。
ps -eo stat,ppid,pid,comm | awk '$1 ~ /^Z/'找出僵尸及其父进程;- 看父进程在做什么:
cat /proc/{ppid}/stack(需 root)、strace -p {ppid},确认它是否卡在read/accept上忘了wait; - 检查
SIGCHLD是否被设为SIG_IGN或SA_NOCLDWAIT——如果是,waitpid必然ECHILD,这不是 bug; - 检查父进程是否注册了
SIGCHLD处理器但只waitpid一次; - 若父进程是容器里的 PID 1,确认它是否实现了回收逻辑;没有就用
--init或tini; - 孤儿排查用
ps -eo pid,ppid,stat,comm,找ppid=1但仍在跑的业务进程,确认它是否该被过继。
2.7.7 fork() 的内核路径:从 kernel_clone() 到 copy_process()
这一节回答一个问题:用户态一次 fork(),内核按什么顺序复制哪些对象,失败时又按什么顺序回滚?只有把顺序记住,才能解释「为什么子进程的 fd 已复制、但记录锁没复制」这类看似矛盾的现象。
一次 fork 的调用链
fork() 在 glibc 里没有秘密,它就是一次系统调用;在 x86-64 上走 syscall 指令进入 entry_SYSCALL_64,由 syscall 表分派到 __x64_sys_fork。Linux 6.8 的 kernel/fork.c 把三个入口收敛到同一个 kernel_clone():
| 用户态入口 | 内核入口 | 关键参数 | 子进程如何返回 |
|---|---|---|---|
fork() | SYSCALL_DEFINE0(fork) | exit_signal = SIGCHLD | 复制当前栈,返回 0 |
vfork() | SYSCALL_DEFINE0(vfork) | CLONE_VM | CLONE_VFORK | SIGCHLD | 共享栈,父进程挂起 |
clone(fn, stack, flags, ...) | SYSCALL_DEFINE5(clone) | 用户给 flags 与子栈 | 切到新栈,返回 0 |
clone3(&args, size) | SYSCALL_DEFINE2(clone3) | struct clone_args | 切到新栈,返回 0 |
fork()/vfork() 的 SYSCALL_DEFINE0 只负责把参数填成 kernel_clone_args,真正的复制在 copy_process()。下面这段是 kernel_clone() 的主干(kernel/fork.c,Linux 6.8,省略错误分支):
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
pid_t nr;
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
if (IS_ERR(p))
return PTR_ERR(p);
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, args->parent_tid);
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
wake_up_new_task(p);
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;
}
读这段代码要抓住三件事:
copy_process()返回的是struct task_struct *,不是 PID。PID 在复制完成后才通过pid_vnr()取出来;CLONE_PARENT_SETTID就是在这一刻把 PID 写回父进程给的地址。- 新进程在
wake_up_new_task()之前不可运行。所有资源复制完成后才入队,所以子进程看不到「复制到一半」的状态。 vfork的父进程阻塞点不在调度器里,而在wait_for_vfork_done()的 completion 上;子进程exec或exit时complete_vfork_done()唤醒它。这解释了为什么vfork子进程不 exec 会把父进程永久挂起。
copy_process() 的资源复制顺序
copy_process() 逐项复制,任何一步失败都跳到对应的 bad_fork_cleanup_* 标签回滚。Linux 6.8 的顺序如下(kernel/fork.c,节选):
retval = copy_semundo(clone_flags, p);
retval = copy_files(clone_flags, p, args->no_files);
retval = copy_fs(clone_flags, p);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
retval = copy_mm(clone_flags, p);
retval = copy_namespaces(clone_flags, p);
retval = copy_io(clone_flags, p);
retval = copy_thread(p, args);
把函数名翻译成用户态可见对象:
| 内核函数 | 复制的对象 | 用户态可见后果 | 对应 flags |
|---|---|---|---|
dup_task_struct() | task_struct、内核栈 | 新 PID/TID、独立调度实体 | 总是 |
copy_creds() | cred、capability 集合 | UID/GID 四件套、cap 继承 | 总是(CLONE_THREAD 时共享) |
copy_files() | files_struct、fd 表 | fd 表独立、表项指向同一 struct file | CLONE_FILES 则共享 |
copy_fs() | fs_struct | cwd、root、umask 独立 | CLONE_FS 则共享 |
copy_sighand() | sighand_struct | 信号处置表独立 | CLONE_SIGHAND 则共享 |
copy_signal() | signal_struct | 未决信号清空、定时器清零、RLIMIT_* 复制 | CLONE_THREAD 则共享 |
copy_mm() | mm_struct、页表 | COW 共享物理页 | CLONE_VM 则共享 |
copy_namespaces() | nsproxy | 命名空间成员关系 | CLONE_NEW* 则新建 |
copy_io() | io_context | AIO 上下文 | CLONE_IO 则共享 |
copy_thread() | 寄存器、栈指针、TLS | 子进程从哪条指令开始跑 | CLONE_SETTLS 设 TLS |
这张表能解释两个常见疑问。第一,为什么 fork 后父子 fd 偏移共享,但 cwd 不共享:fd 表复制后表项仍指向同一个 struct file(偏移在 struct file 里),而 fs_struct 是每个进程一份的副本,子进程 chdir 不影响父进程。第二,为什么 fork 后 alarm 消失:copy_signal() 为新进程分配了新的 signal_struct,定时器字段被清零;只有 CLONE_THREAD 才共享 signal_struct。
copy_process() 内部用 goto bad_fork_cleanup_* 形成一条与复制顺序相反的清理链:copy_thread 失败清理 io,copy_io 失败清理 namespaces,一直到 copy_files 失败清理 semundo。任一资源复制失败,已经建立的资源全部释放,fork() 返回 -1,父进程看不到半成品子进程。
COW 在页表层的实现
copy_mm() 在 CLONE_VM 未设置时调用 dup_mm(),后者复制 mm_struct 并调用 dup_mmap() 遍历每一个 vm_area_struct。对私有可写映射,copy_page_range() 复制页表项,并把父子双方的 PTE 都设为只读;这一步不复制数据页。可以用下面的控制流示意(mm/memory.c,非逐行源码):
copy_page_range(dst_mm, src_mm, vma):
for each page table page covering vma:
copy the page-table page
for each present PTE:
if PTE is writable and vma is private:
ptep_set_wrprotect(src) # 父进程也变只读
pte = pte_wrprotect(pte) # 子进程只读
get_page(page) # 引用计数 +1
install pte into child
写入时缺页,do_wp_page() 判断 PageAnon() 与 page_count():引用计数为 1 时可以直接恢复可写(没有别人共享),大于 1 时才真正分配新页并复制。这解释了一个容易忽略的优化:如果 fork 后父进程先退出,子进程写那页时引用计数已降为 1,内核不必复制。
fork() 的成本与虚拟地址空间大小成正比(要复制页表页),与 RSS 只间接相关。一个 mmap 了 1 TiB 但只触碰 4 KiB 的进程,fork 仍要复制覆盖 1 TiB 的页表层级(通常几十 MiB)。CLONE_VM 完全跳过页表复制,所以线程创建比进程创建快一到两个数量级;代价是地址空间不再隔离。
clone() 的 flags 位表
clone() 的 flags 是一个 64 位整数,低 8 位是「子进程退出时发给父进程的信号」(CSIGNAL 掩码 0xff,fork 用 SIGCHLD,线程用 0),其余位选择共享哪些资源。下表按位列出 Linux 6.8 支持的标志:
| flag | 值 | 共享/新建什么 | 典型使用者 |
|---|---|---|---|
CLONE_VM | 0x00000100 | 共享地址空间与页表 | 线程、vfork |
CLONE_FS | 0x00000200 | 共享 fs_struct(cwd/root/umask) | 线程 |
CLONE_FILES | 0x00000400 | 共享 fd 表 | 线程 |
CLONE_SIGHAND | 0x00000800 | 共享信号处置表 | 线程 |
CLONE_PIDFD | 0x00001000 | 返回 pidfd 到 parent_tid | 服务管理器 |
CLONE_PTRACE | 0x00002000 | 子进程继续被 trace | 调试器 |
CLONE_VFORK | 0x00004000 | 父进程挂起到子进程 exec/exit | vfork、posix_spawn |
CLONE_PARENT | 0x00008000 | 子进程的父进程是调用者的父进程 | 特殊代理进程 |
CLONE_THREAD | 0x00010000 | 同一线程组(同 TGID) | pthread |
CLONE_NEWNS | 0x00020000 | 新 mount 命名空间 | 容器 |
CLONE_SYSVSEM | 0x00040000 | 共享 System V 信号量 undo 列表 | 线程 |
CLONE_SETTLS | 0x00080000 | 设置子进程 TLS | pthread |
CLONE_PARENT_SETTID | 0x00100000 | 把子 PID 写到父进程给的地址 | 线程库 |
CLONE_CHILD_CLEARTID | 0x00200000 | 子进程退出时清零 child_tid 并 futex 唤醒 | pthread join |
CLONE_DETACHED | 0x00400000 | 已废弃,忽略 | — |
CLONE_UNTRACED | 0x00800000 | 阻止父进程强制 CLONE_PTRACE | 调试器 |
CLONE_CHILD_SETTID | 0x01000000 | 把子 TID 写到子进程地址 | 线程库 |
CLONE_NEWCGROUP | 0x02000000 | 新 cgroup 命名空间 | 容器 |
CLONE_NEWUTS | 0x04000000 | 新 UTS 命名空间(hostname) | 容器 |
CLONE_NEWIPC | 0x08000000 | 新 IPC 命名空间 | 容器 |
CLONE_NEWUSER | 0x10000000 | 新 user 命名空间 | 无根容器 |
CLONE_NEWPID | 0x20000000 | 新 PID 命名空间 | 容器 |
CLONE_NEWNET | 0x40000000 | 新网络命名空间 | 容器 |
CLONE_IO | 0x80000000 | 共享 io_context | 很少用 |
CLONE_CLEAR_SIGHAND | 0x100000000 | 子进程信号处置全部复位 | 5.5+ |
CLONE_INTO_CGROUP | 0x200000000 | 子进程直接放进指定 cgroup v2 | 5.7+ |
几条组合规则在内核里是硬性的,违反直接 EINVAL:
CLONE_THREAD必须同时有CLONE_SIGHAND;CLONE_SIGHAND必须同时有CLONE_VM;CLONE_THREAD不能与CLONE_NEWUSER/CLONE_NEWPID同用;CLONE_NEWUSER不能与CLONE_FS同用(用户命名空间的fs_struct语义会冲突);CLONE_PIDFD与CLONE_PARENT_SETTID指向同一地址时非法;CLONE_VM但既没有CLONE_THREAD也没有CLONE_VFORK时,内核会警告并拒绝(容易出现两个进程共享地址空间却互不协调)。
- 「
clone的stack参数是栈底」——它是栈顶,且必须按 16 字节对齐(x86-64 的 ABI 要求)。传buf而不是buf + size,子进程一压栈就写坏相邻内存。 - 「
CLONE_VM就是线程」——线程还需要CLONE_THREAD(同 TGID、共享signal_struct)。只有CLONE_VM的两个进程共享地址空间,但各自有独立的信号与退出状态,ps -L看不到第二个线程。 - 「
clone子进程可以return」——glibc 包装函数在fn返回后会走退出路径,但如果你用裸syscall(SYS_clone),子进程返回到调用点继续执行父进程的代码,几乎必然出错。裸 clone 的子进程必须自己syscall(SYS_exit, 0)。 - 「
CLONE_CHILD_CLEARTID只是清个变量」——它还会在清零后执行futex(FUTEX_WAKE),这是pthread_join的底层机制。清错地址会让 join 永久睡眠。
clone3():结构体版本与大小协商
clone() 的参数已经多到需要 5 个寄存器加栈上传参,Linux 5.3 引入 clone3(),把所有参数放进一个可扩展的结构体:
struct clone_args {
__aligned_u64 flags; /* 同 clone 的 flags */
__aligned_u64 pidfd; /* CLONE_PIDFD:返回 pidfd 的地址 */
__aligned_u64 child_tid; /* CLONE_CHILD_SETTID */
__aligned_u64 parent_tid; /* CLONE_PARENT_SETTID */
__aligned_u64 exit_signal; /* 子进程退出信号,默认 SIGCHLD */
__aligned_u64 stack; /* 子进程栈顶 */
__aligned_u64 stack_size; /* 5.5+:栈大小,用于自动增长 */
__aligned_u64 tls; /* CLONE_SETTLS */
__aligned_u64 set_tid; /* 5.7+:指定 PID 数组地址 */
__aligned_u64 set_tid_size; /* 数组长度,按 PID 命名空间层级 */
__aligned_u64 cgroup; /* 5.7+:CLONE_INTO_CGROUP 的目标 cgroup fd */
};
clone3() 的第二个参数是结构体大小,内核用「大小协商」实现向前兼容:旧内核看到比自己认识的结构体更大时返回 EINVAL,调用者应退回到较小的结构体或 clone()。glibc 没有为 clone3 提供包装,需要用 syscall(SYS_clone3, &args, sizeof args),并且必须处理 ENOSYS(旧内核或 seccomp 拦截):
long ret = syscall(SYS_clone3, &args, sizeof args);
if (ret == -1 && errno == ENOSYS) {
/* 退回 clone():功能子集,但至少能跑 */
ret = clone(child_fn, stack_top, (int)args.flags, NULL);
}
clone3 独有的能力是 set_tid:它允许调用者在新 PID 命名空间里指定子进程的 PID,容器运行时用它构造「容器内 PID 1」以及可预测的进程号。没有 CAP_CHECKPOINT_RESTORE(或 CAP_SYS_ADMIN)时会被拒绝,这是 EPERM 的来源之一。
vfork() 的真实语义
vfork() 不是「更快的 fork」,而是「借用父进程地址空间的 fork」:内核用 CLONE_VM | CLONE_VFORK 创建子进程,父进程在 wait_for_vfork_done() 上睡眠,直到子进程调用 execve() 或 _exit()。子进程与父进程共享同一套页表和同一个栈,所以:
- 子进程修改任何局部变量,父进程恢复后看到的是被改过的值;
- 子进程从函数
return,会返回到父进程的调用栈,行为未定义; - 子进程调用
exit(),会 flush 父进程的 stdio 缓冲区并运行父进程的atexit处理器; - 子进程调用
longjmp()、信号处理器、任何可能改栈的库函数,都可能破坏父进程。
正确写法只有一个形状:
pid_t pid = vfork();
if (pid == 0) {
execve(path, argv, environ); /* 唯一目的 */
_exit(127); /* exec 失败也必须 _exit */
}
现代代码不应再手写 vfork。posix_spawn() 在 glibc 里正是用 clone(CLONE_VM | CLONE_VFORK) 实现,把「共享地址空间、立刻 exec、失败只 _exit」封装好,还额外处理了 signal mask、调度属性、file actions(2.7.4)。vfork 唯一还有意义的场景是极老的内核或没有 posix_spawn 的嵌入式 libc。
在没有 COW 的早期 Unix 上,fork 要物理复制整个地址空间,而 fork 之后往往立刻 exec,复制全是浪费。vfork 用「共享 + 挂起父进程」把这次复制降为零。COW 出现后,fork+exec 的成本已经接近页表复制,vfork 的收益只剩下「不复制页表」;但它的约束和未定义行为远多于收益。
失败账本:fork/clone 的 errno
| errno | 触发条件 | 怎么确认 |
|---|---|---|
EAGAIN | 达到 RLIMIT_NPROC(按真实 UID 统计,不是按进程);达到 kernel.threads-max;cgroup v2 pids.max;kernel.pid_max 耗尽;user namespace 的 pids.max | ulimit -u、cat /proc/sys/kernel/threads-max、cat /sys/fs/cgroup/pids.max、cat /proc/sys/kernel/pid_max |
ENOMEM | 内核无法分配 task_struct、内核栈、页表;内存 cgroup 到顶 | dmesg、/sys/fs/cgroup/memory.events 的 oom 计数 |
EPERM | clone 请求了需要特权的 flag(如 CLONE_NEWPID 无 CAP_SYS_ADMIN、CLONE_INTO_CGROUP 无权限) | 检查 capability 与 seccomp |
EINVAL | flags 组合非法、clone3 结构体大小不被支持、栈未对齐 | 对照上一节的组合规则 |
ENOSYS | 内核不支持 clone3,或被 seccomp 过滤 | grep clone3 /proc/kallsyms(需 root)或试 clone() |
EFAULT | parent_tid/child_tid/set_tid 指向不可写内存 | 检查指针与 CLONE_*_SETTID 是否配对 |
RLIMIT_NPROC 的统计口径值得单独记:它按真实用户 ID 统计该用户当前拥有的进程/线程数,而不是按单个进程。一个以 nobody 运行的服务 fork 出 200 个线程后,同一 UID 下的另一个进程也可能开始 EAGAIN。容器里更常见的上限是 cgroup pids.max,它统计的是 cgroup 内的任务数(线程也算),与 RLIMIT_NPROC 相互独立。
下面这个程序用 clone() 手工构造一个「共享地址空间、但不共享线程组」的子进程,并与 fork() 对比,验证 flags 到底共享了什么。
/* clone_demo.c:clone(CLONE_VM|...) 与 fork() 的可见性对比 */
#include <errno.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
#define STACK_SIZE (1u << 20)
static int shared_counter; /* clone 子进程直接改这里 */
static pid_t child_tid; /* CLONE_CHILD_SETTID 写入子 TID */
static char child_stack[STACK_SIZE] __attribute__((aligned(16)));
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static int clone_child(void *arg)
{
(void)arg;
shared_counter = 42; /* 共享地址空间:父进程可见 */
/* 不再手工写 child_tid:让 CLONE_CHILD_SETTID 自己写,才能验证该 flag */
return 0;
}
int main(void)
{
int flags = CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
CLONE_CHILD_SETTID | SIGCHLD;
/* 栈顶 = 数组末地址;x86-64 要求 16 字节对齐 */
/* glibc clone 变参顺序:arg, parent_tid, tls, child_tid */
pid_t pid = clone(clone_child, child_stack + STACK_SIZE, flags,
NULL, NULL, NULL, &child_tid);
if (pid == -1)
die("clone");
int status;
if (waitpid(pid, &status, 0) == -1)
die("waitpid clone child");
printf("clone: pid=%ld tid=%ld shared_counter=%d exited=%d\n",
(long)pid, (long)child_tid, shared_counter,
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
/* 对照:fork 的子进程写同一变量,父进程看不到 */
shared_counter = 0;
pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
shared_counter = 99; /* COW:子进程私有副本 */
_exit(0);
}
if (waitpid(pid, &status, 0) == -1)
die("waitpid fork child");
printf("fork : pid=%ld shared_counter=%d (父进程仍是 0)\n",
(long)pid, shared_counter);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g clone_demo.c -o clone_demo
./clone_demo
clone: pid=48231 tid=48231 shared_counter=42 exited=0
fork : pid=48232 shared_counter=0 (父进程仍是 0)
第一行里 pid == tid 是因为没有 CLONE_THREAD:子进程是新线程组的组长。shared_counter=42 证明 CLONE_VM 让父子看到同一份物理页;第二行证明 fork 的 COW 隔离。把 CLONE_VM 去掉,第一行会变成 shared_counter=0,程序仍然能跑——这正是 flags 可组合的直观体现。
2.7.8 wait 家族:从 wait() 到 waitid() 的全选项
这一节回答:wait、waitpid、waitid、wait3、wait4 到底差在哪,waitid 的 idtype 和 flags 怎么组合,WNOWAIT 与 P_PIDFD 解决什么问题,rusage 的每个字段从哪里来。
五个等待接口的矩阵
| 接口 | 签名要点 | 能等到的事件 | 额外输出 | 限制 |
|---|---|---|---|---|
wait | pid_t wait(int *status) | 任一子进程退出/停止 | 无 | 无法指定进程,无法非阻塞 |
waitpid | pid_t waitpid(pid_t pid, int *status, int options) | 指定/任一组子进程 | 无 | pid 语义有四种重载 |
waitid | int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options) | 按 ID 类型选,事件由 flags 选 | siginfo_t | 成功返回 0,失败 -1 |
wait3 | pid_t wait3(int *status, int options, struct rusage *ru) | 任一子进程 | rusage | 已过时,BSD 兼容 |
wait4 | pid_t wait4(pid_t pid, int *status, int options, struct rusage *ru) | 指定/任一子进程 | rusage | waitpid + 资源统计 |
waitpid 的第一个参数有四种语义,这是它最容易读错的地方:
pid 取值 | 等待谁 |
|---|---|
> 0 | PID 等于 pid 的那个子进程 |
-1 | 任意一个子进程 |
0 | 与调用者在同一进程组的任意子进程 |
< -1 | 进程组 ID 等于 -pid 的任意子进程 |
waitid 把「等谁」和「等什么事件」拆成两个参数,因此表达能力更强:
idtype | id 的含义 | 引入版本 |
|---|---|---|
P_ALL | 忽略 id,等待任意子进程 | POSIX |
P_PID | 等待 PID 等于 id 的子进程 | POSIX |
P_PGID | 等待进程组等于 id 的子进程 | POSIX |
P_PIDFD | id 是 pidfd(不是 PID) | Linux 5.4 |
P_PIDFD 的价值在于消除 PID 回绕带来的竞态:pidfd_open(pid) 得到的 fd 绑定到那个进程对象,即使目标退出、PID 被复用,fd 也不会指向新进程。服务管理器(systemd、容器运行时)用 pidfd 发信号、等待退出,避免「kill 错进程」。
waitid 的 flags 与事件选择
| flag | 值 | 含义 | 备注 |
|---|---|---|---|
WEXITED | 0x4 | 等待退出事件 | 与 WSTOPPED/WCONTINUED 可组合 |
WSTOPPED | 0x2 | 等待停止事件 | 等价于 waitpid 的 WUNTRACED |
WCONTINUED | 0x8 | 等待 SIGCONT 继续事件 | Linux 2.6.10+ |
WNOHANG | 0x1 | 非阻塞,没有事件立即返回 | 返回 0,si_pid == 0 |
WNOWAIT | 0x01000000 | 只取信息,不回收子进程 | 子进程仍是僵尸,可再次等待 |
__WNOTHREAD | 0x20000000 | 只等调用线程的子进程 | Linux 专有 |
__WALL | 0x40000000 | 等待所有子进程,包括 clone 的 | Linux 专有 |
__WCLONE | 0x80000000 | 只等 clone 且退出信号不是 SIGCHLD 的 | Linux 专有 |
waitid 必须至少指定一个事件位,否则 EINVAL。waitpid 的 options 则是另一套常量(WNOHANG、WUNTRACED、WCONTINUED),两套名字相似但值不同,不能混用。
- 「
waitid返回子进程 PID」——成功时返回 0,子进程信息在siginfo_t里。用返回值判断「等到了谁」会永远得到 0。 - 「
WNOHANG没事件时si_pid是 -1」——waitid返回 0,si_pid == 0;-1只表示出错(如ECHILD)。 - 「
WNOWAIT等于不等待」——它照常阻塞等待事件,只是不把子进程从僵尸队列摘除。事件已经被「看过」,下次wait仍能拿到同一个退出状态。
状态字与 siginfo_t 的解码
wait/waitpid/wait4 返回的 int status 是位打包的,必须用宏解码,不能直接和退出码比较:
| 宏 | 含义 | 位布局(Linux/glibc) |
|---|---|---|
WIFEXITED(status) | 正常退出 | (status & 0x7f) == 0 |
WEXITSTATUS(status) | 退出码低 8 位 | (status >> 8) & 0xff |
WIFSIGNALED(status) | 被信号杀死 | ((status & 0x7f) + 1) >> 1 > 0 |
WTERMSIG(status) | 致死信号编号 | status & 0x7f |
WCOREDUMP(status) | 是否产生 core | status & 0x80 |
WIFSTOPPED(status) | 被信号停止 | (status & 0xff) == 0x7f |
WSTOPSIG(status) | 停止信号编号 | (status >> 8) & 0xff |
WIFCONTINUED(status) | 被 SIGCONT 继续 | status == 0xffff |
waitid 的 siginfo_t 把这些信息拆成字段:si_pid(谁)、si_uid(真实 UID)、si_status(退出码或信号号)、si_code(事件类型)。si_code 的取值决定了 si_status 怎么读:
si_code | 含义 | si_status 是什么 |
|---|---|---|
CLD_EXITED | 正常退出 | 退出码(0–255) |
CLD_KILLED | 被信号杀死,无 core | 信号编号 |
CLD_DUMPED | 被信号杀死,有 core | 信号编号 |
CLD_TRAPPED | 被 ptrace 捕获 | 陷阱信号 |
CLD_STOPPED | 被信号停止 | 停止信号 |
CLD_CONTINUED | 被 SIGCONT 继续 | SIGCONT |
CLD_* 与 WIF* 是两套编码,不要交叉使用:si_code == CLD_EXITED 时读 si_status,而不是 WEXITSTATUS(si_status)。
WNOWAIT:先看后收
WNOWAIT 的经典用途是「两阶段回收」:先确认子进程的死因与资源用量,再决定是否回收。比如一个监控进程想记录每个子进程的 rusage,又不想在记录过程中让 PID 被复用;或者调试器想在回收前读取 /proc/{pid}/stat。
siginfo_t si = {0};
if (waitid(P_PID, pid, &si, WEXITED | WNOWAIT) == -1)
/* 错误 */;
/* 此时子进程仍是僵尸:/proc/<pid>/stat 的 state 是 Z */
/* 读完信息后,再真正回收 */
if (waitpid(pid, NULL, 0) == -1)
/* 错误 */;
注意 WNOWAIT 与 WNOHANG 可以组合:WEXITED | WNOHANG | WNOWAIT 表示「如果有已退出的子进程,只报告不回收」。这对「先扫一遍所有僵尸再统一处理」很有用。
wait4 与 rusage 的字段
wait4() 在回收的同时把子进程(及其已回收的后代)的资源统计写到 struct rusage。字段来自内核的 task_struct 累加,单位要看清:
| 字段 | 单位 | 含义 | 何时为 0 |
|---|---|---|---|
ru_utime | timeval | 用户态 CPU 时间 | 子进程没跑够一个 tick |
ru_stime | timeval | 内核态 CPU 时间 | 同上 |
ru_maxrss | KiB | 峰值 RSS | 子进程从未驻留(几乎不可能) |
ru_minflt | 次 | 次缺页(无 I/O) | 页全部预热 |
ru_majflt | 次 | 主缺页(有 I/O) | 全部命中页缓存 |
ru_inblock | 块(512 B) | 读磁盘次数 | 全走页缓存 |
ru_oublock | 块(512 B) | 写磁盘次数 | 写被缓存吸收 |
ru_nvcsw | 次 | 自愿上下文切换 | 从不阻塞 |
ru_nivcsw | 次 | 非自愿上下文切换 | 从不被抢占 |
ru_nsignals | 个 | 收到的信号数 | 无信号 |
ru_maxrss 在 Linux 上是 KiB,在 macOS/BSD 上是字节;跨平台代码要除以 1024 的差异。ru_inblock/ru_oublock 统计的是实际块设备 I/O,不是 read/write 调用次数,页缓存命中时不增加。
wait3/wait4 是 BSD 遗产,POSIX 没有标准化。wait4 在 Linux、macOS、FreeBSD 都有,但 struct rusage 的字段和单位有差异。可移植代码要么用 waitpid + getrusage(RUSAGE_CHILDREN)(统计所有已回收子进程的累计值),要么自己封装 wait4 并用 #ifdef 处理单位。
内核侧:do_wait() 的控制流
wait4() 进入内核后走 kernel_wait4() → do_wait()。控制流可以用下面的伪代码概括(kernel/exit.c,非逐行源码):
do_wait(wo):
repeat:
for each child p of current:
ret = wait_consider_task(wo, 0, p)
if ret != 0: return ret # 找到事件或出错
if wo->notask_error == 0 and wo->wo_flags & WNOHANG:
return 0 # 非阻塞:无事件
set_current_state(TASK_INTERRUPTIBLE)
if signal_pending(current): return -ERESTARTSYS
schedule() # 睡眠,等 SIGCHLD 唤醒
until 有事件
关键点:
wait的唤醒靠SIGCHLD:子进程退出时do_notify_parent()给父进程发SIGCHLD,父进程从schedule()醒来重新扫描。如果SIGCHLD被阻塞,wait仍能被唤醒(内核在wait_task_zombie()里直接wake_up_parent()),但用户态的sigsuspend等不到。WNOWAIT在wait_consider_task()里体现为「报告但不release_task()」:事件被复制到siginfo,子进程留在僵尸链上。ECHILD在扫描不到任何子进程时设置;WNOHANG且有子进程但无事件时返回 0,不设置errno。
下面这个程序把 waitid 的停止、退出、WNOWAIT 与 wait4 的 rusage 串起来,并直接读 /proc/{pid}/stat 验证僵尸状态。
/* waitid_peek.c:WSTOPPED → 手动 SIGCONT → WNOWAIT 窥视 → wait4 回收 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
/* 读 /proc/<pid>/stat 的第三个字段(state);comm 可能含空格,先找最后一个 ')' */
static char proc_state(pid_t pid)
{
char path[64], buf[512];
snprintf(path, sizeof path, "/proc/%ld/stat", (long)pid);
FILE *fp = fopen(path, "r");
if (fp == NULL)
return '?';
if (fgets(buf, sizeof buf, fp) == NULL) {
fclose(fp);
return '?';
}
fclose(fp);
char *p = strrchr(buf, ')');
if (p == NULL || p[1] != ' ')
return '?';
return p[2];
}
int main(void)
{
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
/* 子进程:先分配并触碰 8 MiB,制造可观测的 rusage */
size_t len = 8u << 20;
char *p = malloc(len);
if (p == NULL)
_exit(120);
for (size_t i = 0; i < len; i += 4096)
p[i] = 1;
raise(SIGSTOP); /* 停在这里等父进程 */
p[0] = 2; /* 继续后改一页 */
_exit(7);
}
siginfo_t si;
memset(&si, 0, sizeof si);
/* 1. 等停止事件:WSTOPPED 不能省 */
if (waitid(P_PID, (id_t)pid, &si, WSTOPPED) == -1)
die("waitid WSTOPPED");
printf("stopped : code=%d status=%d state=%c\n",
si.si_code, si.si_status, proc_state(pid));
if (si.si_code != CLD_STOPPED || si.si_status != SIGSTOP) {
fprintf(stderr, "unexpected stop event\n");
return EXIT_FAILURE;
}
if (kill(pid, SIGCONT) == -1)
die("kill SIGCONT");
/* 2. 等退出事件,但先不回收 */
memset(&si, 0, sizeof si);
if (waitid(P_PID, (id_t)pid, &si, WEXITED | WNOWAIT) == -1)
die("waitid WEXITED|WNOWAIT");
printf("peek : code=%d status=%d state=%c\n",
si.si_code, si.si_status, proc_state(pid));
if (si.si_code != CLD_EXITED || si.si_status != 7) {
fprintf(stderr, "unexpected exit event\n");
return EXIT_FAILURE;
}
/* 3. 真正回收,同时拿 rusage */
struct rusage ru;
int status;
if (wait4(pid, &status, 0, &ru) == -1)
die("wait4");
printf("reaped : exited=%d code=%d maxrss=%ldKiB minflt=%ld\n",
WIFEXITED(status), WEXITSTATUS(status),
(long)ru.ru_maxrss, (long)ru.ru_minflt);
printf("after : /proc/%ld/stat state=%c (进程已消失)\n",
(long)pid, proc_state(pid));
/* 4. 再等一次:ECHILD */
errno = 0;
if (waitid(P_PID, (id_t)pid, &si, WEXITED | WNOHANG) == -1)
printf("again : errno=%d (%s)\n", errno, strerror(errno));
else
printf("again : si_pid=%d\n", (int)si.si_pid);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g waitid_peek.c -o waitid_peek
./waitid_peek
stopped : code=2 status=19 state=T
peek : code=1 status=7 state=Z
reaped : exited=1 code=7 maxrss=9876KiB minflt=2103
after : /proc/48255/stat state=? (进程已消失)
again : errno=10 (No child processes)
关键行是 peek:si_code=1(CLD_EXITED)、si_status=7,而 /proc 里状态仍是 Z,说明 WNOWAIT 只报告不回收。reaped 行里 maxrss 约为 8 MiB 加上 libc/栈,minflt 是子进程 8 MiB 触碰产生的次缺页数。CLD_STOPPED 的数值由 <sys/wait.h> 提供(glibc 里是 2),CLD_EXITED 是 1;不同 libc 常量值可能不同,代码应使用宏而不是硬编码。
- 只有一个子进程、不需要统计:
waitpid(pid, &st, 0); - 要处理停止/继续:
waitpid(pid, &st, WUNTRACED | WCONTINUED),或waitid显式选事件; - 要死因与 UID:
waitid,读si_code/si_uid; - 要资源用量:
wait4,或waitid+getrusage(RUSAGE_CHILDREN)(累计值,不是单个子进程); - 要避免 PID 复用竞态:
pidfd_open+waitid(P_PIDFD, ...); - 要先观察后回收:
WNOWAIT,读完/proc或rusage再waitpid。
2.7.9 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
说出 fork 后哪些资源复制、哪些共享 | 至少覆盖 fd、信号、锁、定时器 | 2.7.1 |
| 用 COW 解释 fork 后 RSS 的变化 | 能说清「写第一字节才复制整页」 | 2.7.2 |
| 解释多线程 fork 的死锁窗口 | 能举 malloc 锁的例子 | 2.7.3 |
区分 exit/_exit 的四步差异 | 能复现 stdio 重复输出 | 2.7.5 |
用 waitpid 处理四类事件 | 代码能编译并跑出 stopped/continued | 2.7.6 |
| 解释僵尸占什么、怎么回收 | 能说出 SA_NOCLDWAIT 的代价 | 2.7.6 |
Q1 父进程有一个 1 GiB 的匿名私有映射,fork 后子进程立刻 _exit。这期间物理内存会增加 1 GiB 吗?为什么?
Q2 父进程打开 /tmp/log(O_WRONLY|O_APPEND),fork 后子进程先写 100 字节,父进程再写 100 字节。两次写的偏移各是多少?如果父进程在 fork 前 dup 了 fd,结论变吗?
Q3 子进程用 exit(0) 而不是 _exit(0),在什么条件下会看到重复输出?为什么在终端上常常看不到?
Q4 一个服务每秒 fork 一个子进程执行任务,但从不 wait。三天后 fork 返回 EAGAIN。解释原因和修复。
Q5 waitpid(-1, &st, WNOHANG) 返回 0 和返回 -1 且 errno==ECHILD 分别意味着什么?
答案
A1 不会。fork 只复制页表并把 PTE 标只读,物理页引用计数加一。子进程 _exit 前没有写入,就没有 COW 复制。地址空间的虚拟大小是 1 GiB,但驻留集与父进程共享。
A2 子进程写的是 0→100(O_APPEND 每次写定位到当前末尾,fork 时文件大小是 0);父进程写的是 100→200。因为 fd 表虽复制,表项指向同一 struct file,偏移与 O_APPEND 状态共享。dup 不改变结论:dup 本来就指向同一 struct file。
A3 当 fork 前 stdio 缓冲区非空时。最常见的是 stdout 被重定向到文件或管道(全缓冲),数据一直攒着;终端是行缓冲,通常上一条输出以换行结尾、缓冲区已空,所以看不到——但不保证,本程序故意不写换行,在终端上同样会重复。修复:fork 前 fflush(NULL),或子进程用 _exit。
A4 子进程退出后成为僵尸,父进程不 wait,PID 与 task_struct 不释放。累计到 kernel.pid_max 或 cgroup pids.max 后,fork 返回 EAGAIN。修复:父进程用 waitpid(-1, ..., WNOHANG) 定期回收,或设 SA_NOCLDWAIT/SIG_IGN(放弃退出状态),或调整架构不再每任务 fork。
A5 返回 0:有子进程存在,但没有已退出/停止的(WNOHANG 下正常);返回 -1 且 ECHILD:没有可等待的子进程了(都已回收,或 SIGCHLD 被忽略)。两者都不是错误,但处理逻辑不同。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 24 章 进程的创建 | 2.7.1–2.7.4 | 合并(fork/vfork;24.4/24.5 的 who-runs-first 与信号同步见 2.7.2/2.7.6) |
| K | 第 25 章 进程的终止 | 2.7.5 | 完整(exit/_exit、atexit、stdio 交互) |
| K | 第 26 章 监控子进程 | 2.7.6 | 完整(wait 家族、僵尸、SIGCHLD) |
| K | 第 28 章 详述进程创建和程序执行 | 2.7.3–2.7.4 | 精选(clone flags、fork 成本) |
| K | 第 6 章 进程 | 2.7.1 | 背景(进程号、/proc) |
| C | 第 8 章 8.2–8.4 | 2.7.1、2.7.5–2.7.6 | 完整(进程抽象、fork/wait/exit) |
| O | 第 4–5 章(进程抽象、进程 API) | 2.7.1、2.7.6 | 背景(状态机与 API 语义) |
| G | 第 16 章 程序管理与 SELinux 初探 | 2.7.6 | 背景(ps 状态、僵尸的观测) |
下一站:2.8 程序执行与子进程监控 接着讲
fork的另一半:execve如何把当前进程替换成新程序,哪些属性会被保留,system()为什么是个陷阱,以及怎样拿到子进程的资源用量。