Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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() 的返回值与继承清单

定义 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()` 的五个误解
  1. 「fork 会把父进程内存复制一份」——逻辑上复制,物理上共享。只有某一方写入的页才复制,见 2.7.2。
  2. 「父子进程的 fd 是各自独立的」——fd 表独立,但表项指向同一个 struct file,文件偏移共享。父进程读 100 字节,子进程的 read 从 101 开始。
  3. 「fork 后子进程会收到父进程未处理的信号」——未决信号被清空。父进程阻塞着的 SIGUSR1 不会传给子进程。
  4. 「fork 会继承父进程的 alarm」——不会。定时器在子进程中清零,这是 SIGALRM 超时逻辑在 fork 后失效的原因。
  5. 「fork 失败只会因为内存不够」——EAGAIN 更常见:RLIMIT_NPROC、cgroup pids.max、全局 kernel.pid_max 都会触发。

2.7.2 写时复制:页表、引用计数与断裂点

fork() 最容易被误解的地方是「复制」。如果真复制,一个 8 GiB 的进程 fork 一次就要 8 GiB 空闲内存,shell 早就不可用了。实际做法是:

  1. 内核为子进程复制页表(不是数据页),把所有私有可写 PTE 改成只读;
  2. 每个物理页的引用计数加一;
  3. 任一方写入只读页时触发缺页异常,内核判断是 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):一个页里只要有一个字节被改,整页复制。

为什么 COW 对 fork+exec 特别划算

fork() 之后紧接着 execve() 是最常见的组合。子进程在 exec 之前通常只改几十个页(栈、少量全局变量),exec 一到,整个地址空间被丢弃。如果 fork 真的复制全部内存,这些复制全是白费。COW 让「fork + exec」的成本约等于「复制页表 + 加载新程序」。

定理 2.7.1 · COW 不改变的可见性规则

设父子进程在 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 被逐页复制。

例 2.7.1 · 为什么子进程 RSS 不是 0

VmRSS 是「该进程页表里映射的、当前驻留在内存中的页」的计数,不区分独占还是共享。fork 后子进程页表指向同一批物理页,这些页对子进程也是驻留的,所以 RSS 立即等于父进程。真正能看出 COW 的是写入后的增量,以及 /proc/meminfo 里 AnonPages 的变化——但后者受全局回收影响,不如 RSS 差值直观。

2.7.3 多线程程序中的 fork():为什么只允许 async-signal-safe 调用

fork() 只复制调用线程,其他线程在子进程中不存在。但其他线程持有的锁、正在写的缓冲区、正在更新的数据结构,都以「被复制的一份」留在子进程内存里。子进程如果去拿那把锁,就永远等不到释放者——因为持有者线程已经不存在了。

定义 2.7.2 · `fork()` 后子进程的调用限制

POSIX 规定:在多线程程序中,fork() 之后、execve() 之前,子进程只能调用 async-signal-safe 函数(见 2.10 的列表)。malloc()、printf()、pthread_mutex_lock()、syslog() 都不在其中。原因是这些函数可能依赖被其他线程持有的内部锁。

最典型的死锁场景:

  1. 线程 A 调用 malloc(),拿到 glibc 的 arena 锁,尚未释放;
  2. 线程 B 调用 fork(),子进程复制了「锁已被持有」的内存状态;
  3. 子进程调用 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 的四个误解
  1. 「子进程会继承所有线程」——只复制调用 fork() 的那个线程,其他线程消失。子进程的 gettid() 与父进程调用者不同。
  2. 「fork 后调用 printf 没事,我试过」——没事是概率事件。锁恰好没被持有时能跑通,压力下必现。
  3. 「pthread_atfork 能解决所有死锁」——只能处理你注册的锁。malloc 的 arena 锁、stdio 的 FILE 锁、NSS 的锁都不归你管。
  4. 「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() 与退出处理程序

定义 2.7.3 · `exit()` 与 `_exit()`

void exit(int status) 是库函数,它按顺序做四件事:

  1. 按注册的逆序调用 atexit()/on_exit() 注册的函数;
  2. flush 并关闭所有打开的 stdio 流;
  3. 删除 tmpfile() 创建的临时文件;
  4. 调用 _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 的责任留给明确知道自己在做什么的调用者。

关于进程终止的四个误解
  1. 「exit(1) 和 return 1 在 main 里不同」——在 main 里等价(C 标准保证 return 会走 exit)。在其他函数里 return 只是返回,不终止进程。
  2. 「exit() 会立即停止所有线程」——exit() 会终止整个进程(包括其他线程),但在此之前会跑 atexit 与 flush。_exit() 跳过这些步骤。
  3. 「atexit 注册的函数按注册顺序执行」——逆序(LIFO)。先注册的后执行,适合「后申请的资源先释放」。
  4. 「退出码可以任意大」——只有低 8 位有效。exit(1000) 父进程看到的是 232。

2.7.6 僵尸、孤儿与 wait 家族

进程终止后并不会立刻消失。内核要保留它的退出状态、资源用量,等父进程来取。这段时间里进程处于 TASK_ZOMBIE 状态。

进程状态与迁移 运行、可中断睡眠、不可中断睡眠、停止、僵尸与退出。 进程状态机(Linux 6.x,task_struct->__state)TASK_RUNNING可运行 / 正在运行TASK_INTERRUPTIBLE可被信号唤醒TASK_UNINTERRUPTIBLE不可被信号唤醒TASK_STOPPEDSIGSTOP / 调试TASK_ZOMBIE已退出,等待 waitTASK_TRACED被 ptrace 暂停EXIT_DEAD彻底释放等待事件事件就绪 / 唤醒等待 I/OI/O 完成SIGSTOPSIGCONTexit()wait() 回收ptrace就绪队列只收 TASK_RUNNING;睡眠状态通过等待队列唤醒;ZOMBIE 占的是 task_struct,不是内存页
读图:重点看两条环:可运行 ↔ 可中断睡眠(等待队列 + 唤醒),以及运行 → 僵尸 → 回收。不可中断睡眠(D 状态)不响应信号,常见于磁盘 I/O;大量 D 状态通常意味着 I/O 或文件系统卡住。

读图:TASK_ZOMBIE 是「已退出、等 wait」,它占的是 task_struct 与少量记账信息,不是内存页。图中从 TASK_RUNNING 有一条 exit() 指向 ZOMBIE,再从 ZOMBIE 经 wait() 到 EXIT_DEAD;只有走完这条线,PID 才会被回收。TASK_TRACED 是另一条支路:被 ptrace 暂停的进程不响应普通信号,调试器退出时要负责让它继续或终止。

定义 2.7.4 · 僵尸与孤儿
  • 僵尸(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)。但有两个坑:

  1. 信号会合并。三个子进程几乎同时退出,标准信号只记一次未决,处理器可能只被调用一次。所以处理器里必须用 while 循环把已退出的子进程全部回收,不能只 waitpid 一次。
  2. 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,父进程恰好死在中间,子进程就永远收不到信号。

僵尸与孤儿的排查顺序
  1. ps -eo stat,ppid,pid,comm | awk '$1 ~ /^Z/' 找出僵尸及其父进程;
  2. 看父进程在做什么:cat /proc/{ppid}/stack(需 root)、strace -p {ppid},确认它是否卡在 read/accept 上忘了 wait;
  3. 检查 SIGCHLD 是否被设为 SIG_IGN 或 SA_NOCLDWAIT——如果是,waitpid 必然 ECHILD,这不是 bug;
  4. 检查父进程是否注册了 SIGCHLD 处理器但只 waitpid 一次;
  5. 若父进程是容器里的 PID 1,确认它是否实现了回收逻辑;没有就用 --init 或 tini;
  6. 孤儿排查用 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;
}

读这段代码要抓住三件事:

  1. copy_process() 返回的是 struct task_struct *,不是 PID。PID 在复制完成后才通过 pid_vnr() 取出来;CLONE_PARENT_SETTID 就是在这一刻把 PID 写回父进程给的地址。
  2. 新进程在 wake_up_new_task() 之前不可运行。所有资源复制完成后才入队,所以子进程看不到「复制到一半」的状态。
  3. 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 fileCLONE_FILES 则共享
copy_fs()fs_structcwd、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_contextAIO 上下文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。

定义 2.7.5 · `copy_process()` 的失败回滚

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,内核不必复制。

页表复制的成本与 `CLONE_VM`

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_VM0x00000100共享地址空间与页表线程、vfork
CLONE_FS0x00000200共享 fs_struct(cwd/root/umask)线程
CLONE_FILES0x00000400共享 fd 表线程
CLONE_SIGHAND0x00000800共享信号处置表线程
CLONE_PIDFD0x00001000返回 pidfd 到 parent_tid服务管理器
CLONE_PTRACE0x00002000子进程继续被 trace调试器
CLONE_VFORK0x00004000父进程挂起到子进程 exec/exitvfork、posix_spawn
CLONE_PARENT0x00008000子进程的父进程是调用者的父进程特殊代理进程
CLONE_THREAD0x00010000同一线程组(同 TGID)pthread
CLONE_NEWNS0x00020000新 mount 命名空间容器
CLONE_SYSVSEM0x00040000共享 System V 信号量 undo 列表线程
CLONE_SETTLS0x00080000设置子进程 TLSpthread
CLONE_PARENT_SETTID0x00100000把子 PID 写到父进程给的地址线程库
CLONE_CHILD_CLEARTID0x00200000子进程退出时清零 child_tid 并 futex 唤醒pthread join
CLONE_DETACHED0x00400000已废弃,忽略—
CLONE_UNTRACED0x00800000阻止父进程强制 CLONE_PTRACE调试器
CLONE_CHILD_SETTID0x01000000把子 TID 写到子进程地址线程库
CLONE_NEWCGROUP0x02000000新 cgroup 命名空间容器
CLONE_NEWUTS0x04000000新 UTS 命名空间(hostname)容器
CLONE_NEWIPC0x08000000新 IPC 命名空间容器
CLONE_NEWUSER0x10000000新 user 命名空间无根容器
CLONE_NEWPID0x20000000新 PID 命名空间容器
CLONE_NEWNET0x40000000新网络命名空间容器
CLONE_IO0x80000000共享 io_context很少用
CLONE_CLEAR_SIGHAND0x100000000子进程信号处置全部复位5.5+
CLONE_INTO_CGROUP0x200000000子进程直接放进指定 cgroup v25.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()` 的四个误解
  1. 「clone 的 stack 参数是栈底」——它是栈顶,且必须按 16 字节对齐(x86-64 的 ABI 要求)。传 buf 而不是 buf + size,子进程一压栈就写坏相邻内存。
  2. 「CLONE_VM 就是线程」——线程还需要 CLONE_THREAD(同 TGID、共享 signal_struct)。只有 CLONE_VM 的两个进程共享地址空间,但各自有独立的信号与退出状态,ps -L 看不到第二个线程。
  3. 「clone 子进程可以 return」——glibc 包装函数在 fn 返回后会走退出路径,但如果你用裸 syscall(SYS_clone),子进程返回到调用点继续执行父进程的代码,几乎必然出错。裸 clone 的子进程必须自己 syscall(SYS_exit, 0)。
  4. 「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。

为什么 vfork 曾经很重要

在没有 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.maxulimit -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 计数
EPERMclone 请求了需要特权的 flag(如 CLONE_NEWPID 无 CAP_SYS_ADMIN、CLONE_INTO_CGROUP 无权限)检查 capability 与 seccomp
EINVALflags 组合非法、clone3 结构体大小不被支持、栈未对齐对照上一节的组合规则
ENOSYS内核不支持 clone3,或被 seccomp 过滤grep clone3 /proc/kallsyms(需 root)或试 clone()
EFAULTparent_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 的每个字段从哪里来。

五个等待接口的矩阵

接口签名要点能等到的事件额外输出限制
waitpid_t wait(int *status)任一子进程退出/停止无无法指定进程,无法非阻塞
waitpidpid_t waitpid(pid_t pid, int *status, int options)指定/任一组子进程无pid 语义有四种重载
waitidint waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options)按 ID 类型选,事件由 flags 选siginfo_t成功返回 0,失败 -1
wait3pid_t wait3(int *status, int options, struct rusage *ru)任一子进程rusage已过时,BSD 兼容
wait4pid_t wait4(pid_t pid, int *status, int options, struct rusage *ru)指定/任一子进程rusagewaitpid + 资源统计

waitpid 的第一个参数有四种语义,这是它最容易读错的地方:

pid 取值等待谁
> 0PID 等于 pid 的那个子进程
-1任意一个子进程
0与调用者在同一进程组的任意子进程
< -1进程组 ID 等于 -pid 的任意子进程

waitid 把「等谁」和「等什么事件」拆成两个参数,因此表达能力更强:

idtypeid 的含义引入版本
P_ALL忽略 id,等待任意子进程POSIX
P_PID等待 PID 等于 id 的子进程POSIX
P_PGID等待进程组等于 id 的子进程POSIX
P_PIDFDid 是 pidfd(不是 PID)Linux 5.4

P_PIDFD 的价值在于消除 PID 回绕带来的竞态:pidfd_open(pid) 得到的 fd 绑定到那个进程对象,即使目标退出、PID 被复用,fd 也不会指向新进程。服务管理器(systemd、容器运行时)用 pidfd 发信号、等待退出,避免「kill 错进程」。

waitid 的 flags 与事件选择

flag值含义备注
WEXITED0x4等待退出事件与 WSTOPPED/WCONTINUED 可组合
WSTOPPED0x2等待停止事件等价于 waitpid 的 WUNTRACED
WCONTINUED0x8等待 SIGCONT 继续事件Linux 2.6.10+
WNOHANG0x1非阻塞,没有事件立即返回返回 0,si_pid == 0
WNOWAIT0x01000000只取信息,不回收子进程子进程仍是僵尸,可再次等待
__WNOTHREAD0x20000000只等调用线程的子进程Linux 专有
__WALL0x40000000等待所有子进程,包括 clone 的Linux 专有
__WCLONE0x80000000只等 clone 且退出信号不是 SIGCHLD 的Linux 专有

waitid 必须至少指定一个事件位,否则 EINVAL。waitpid 的 options 则是另一套常量(WNOHANG、WUNTRACED、WCONTINUED),两套名字相似但值不同,不能混用。

关于 `waitid` 的三个误解
  1. 「waitid 返回子进程 PID」——成功时返回 0,子进程信息在 siginfo_t 里。用返回值判断「等到了谁」会永远得到 0。
  2. 「WNOHANG 没事件时 si_pid 是 -1」——waitid 返回 0,si_pid == 0;-1 只表示出错(如 ECHILD)。
  3. 「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)是否产生 corestatus & 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_utimetimeval用户态 CPU 时间子进程没跑够一个 tick
ru_stimetimeval内核态 CPU 时间同上
ru_maxrssKiB峰值 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` 的可移植性

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 常量值可能不同,代码应使用宏而不是硬编码。

选哪个等待接口
  1. 只有一个子进程、不需要统计:waitpid(pid, &st, 0);
  2. 要处理停止/继续:waitpid(pid, &st, WUNTRACED | WCONTINUED),或 waitid 显式选事件;
  3. 要死因与 UID:waitid,读 si_code/si_uid;
  4. 要资源用量:wait4,或 waitid + getrusage(RUSAGE_CHILDREN)(累计值,不是单个子进程);
  5. 要避免 PID 复用竞态:pidfd_open + waitid(P_PIDFD, ...);
  6. 要先观察后回收: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/continued2.7.6
解释僵尸占什么、怎么回收能说出 SA_NOCLDWAIT 的代价2.7.6
即时自测 · 2.7

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.42.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() 为什么是个陷阱,以及怎样拿到子进程的资源用量。