万字拆解UNIX进程控制:fork/exec/exit/wait与工程避坑指南 搞 UNIX 系统编程这行当有一个绕不过去的路口叫进程控制。《UNIX 高级环境编程》第八章把整个多进程编程的骨架放在了一起fork 出来一个进程用 exec 换掉它的身体用 exit 结束它的生命最后由父进程用 wait 收尸。表面上看就四个词可真要读懂背后牵扯出的是地址空间复制、缓冲区迷局、僵尸进程、竞态条件、权限切换等一整套贯穿系统底层的机制。我这篇万字笔记就是把这一章拆成你能直接带进工程里的九块内容重点讲透那些容易翻车的地方。1. 承重墙fork、exec、exit、wait 四者构成的进程生命周期1.1 核心理念先可视化一遍第 8 章本质上讲的是“一个进程怎么来、怎么换芯、怎么退场、怎么被善后”。你可以把进程想象成一本草稿纸fork 是复印机exec 是换了纸上的内容却不换草稿本exit 是撕掉这一页wait 是确认这一页被彻底归档。实际调用顺序往往是这样父进程调用 fork得到一个和自己几乎一样的子进程子进程如果想干别的事调用 exec 系列函数把当前进程映像整个替换掉子进程结束后调用 exit / _exit留下终止状态父进程调用 wait / waitpid得到终止状态并释放子进程残留的进程表项。这一套组合听起来简单但第 8 章的篇幅足以证明它并不简单。书中花了整章来讨论fork 复制了多少东西子进程能不能直接退出什么时候会卡住父子之间执行顺序能不能管得住这些问题的答案直接影响你是不是能写出稳定可靠的网络服务、守护进程和容器类工具。1.2 进程标识符每个进程都有“身份证号”Unix 下每个进程都有唯一标识 PID非负整数基本规则是“当前存活进程里不重复”。PID 0 是调度进程常称为 swapper属于内核层面的进程PID 1 是 init后来在很多 Linux 发行版里是 systemd它是用户态进程的祖宗。父进程 ID 简写为 PPID可以通过 getppid 拿到。有一套非常典型的场景父进程先退出子进程还没退出子进程会变成孤儿进程。孤儿不是没人管而是被内核重新交给 init/systemd 收养之后由它负责回收。反过来如果子进程先退出且父进程不调用 wait子进程就会变成僵尸进程占用一个进程表项无法被 kill 掉。这是后文的重头戏。第 8 章还提到进程组和会话的概念。进程组是一组相关进程的集合每个组有进程组 IDPGID会话是进程组的集合对应一次登录终端会话。shell 里管道两边命令之所以能一起收到 CtrlC就是因为它们属于同一个进程组。理解这些概念是之后看守护进程代码、终端控制代码的前提。2. fork 函数的核心机制与深入拆解2.1 返回值就是我们唯一的“分岔路口”fork 先调用随后返回两次在子进程中返回 0在父进程中返回子进程的 PID失败时返回 -1。这其实是程序用来判断“我现在在哪个进程里”的唯一线索。常规写法的样板代码是pid_t pid fork(); if (pid 0) { // fork 失败通常要记日志并退出 } else if (pid 0) { // 子进程路径 } else { // 父进程路径pid 就是子进程 pid }为什么不是子进程返回自己的 PID而是返回 0根本原因是子进程的 PID 可以在子进程里通过 getpid 拿到没必要靠返回值而父进程如果没有返回值就很难在多个子进程里区分谁是谁。至于失败常见原因有两类一类是系统进程数达到硬限制比如 RLIMIT_NPROC 受限fork 返回 EAGAIN另一类是内存不足无法复制地址空间或分配内核数据结构可能返回 ENOMEM。2.2 fork 到底复制了什么地址空间、文件描述符与缓冲区教科书说法是“子进程获得父进程地址空间的副本”实际上现在主流实现都用写时复制copy-on-write优化子进程先和父进程共享物理内存页只有任何一方写入时才真正复制。所以你不需要担心 fork 一次就复制掉几个 GB 的内存现代内核在这上面已经做得很高效。真正需要注意的是文件描述符这个坑。子进程会从父进程那里继承所有已打开的文件描述符包括 socket、磁盘文件、管道等等。父进程和子进程指向的是同一个内核文件表项也就是说它们共享同一个文件偏移量。这带来一个实际后果父子进程同时对同一个文件描述符做写操作数据会交错写入偏移量互相影响。如果你不想让子进程继续持有某些 fd要在 fork 之后立刻 close如果担心 exec 之后 fd 泄漏给新程序要在打开 fd 时设置 FD_CLOEXEC。第 8 章后面讲 exec 时会反复强调这个细节。缓冲区问题更加隐蔽。标准 I/O 库stdio在 fork 时会把用户空间的缓冲区一起复制给子进程。如果一个缓冲区在 fork 之前已经写入数据但没刷新父进程和子进程各自持有这个缓冲区的副本将来各自刷新时就会出现重复输出。书中有一个非常经典的例子int main(void) { printf(before fork\n); pid_t pid fork(); if (pid 0) { printf(child\n); } else { printf(parent\n); } return 0; }在交互式终端下“before fork”通常只出现一次因为 stdout 是行缓冲遇到换行已经刷出去了。但如果把输出重定向到文件stdout 变成全缓冲“before fork”这个字符串会留在缓冲区里fork 之后父进程和子进程各自持有这个缓冲副本到进程退出时都会刷新一遍结果就是“before fork”被打印了两次。解决方法是fork 之前主动 fflush(NULL) 刷新全部缓冲区子进程里也用 _exit 代替 exit不做标准 I/O 清理。2.3 fork 之后谁先跑靠猜是要吃亏的第 8 章反复强调fork 之后父子进程的执行顺序不确定取决于内核调度器。你永远不能假设子进程一定先执行也不能假设父进程一定先执行。很多时候我们把“让子进程先做事”写在代码里却忘记加同步机制于是出现偶发崩溃。例如子进程依赖父进程先初始化某个文件、先完成某个连接结果父进程还没做子进程已经读到一个空文件或拒绝连接。正确做法是使用后面会讲到的管道同步或者信号、锁、共享内存等手段。凡是涉及父子协作的代码都应该显式同步而不是靠“经验上的执行顺序”碰运气。3. vfork 和 exec 家族从共享内存到程序替换3.1 vfork被遗忘的“上古神器”vfork 和 fork 的历史区别是 vfork 不复制地址空间。子进程直接借用父进程的地址空间而父进程在子进程工作期间被内核挂起直到子进程调用 exec 或 exit 才恢复。早期没有写时复制时fork 复制全部内存成本太高vfork 用来解决“我马上就要 exec 换一个新程序根本不需要复制老地址空间”的场景。现代 Linux 中由于写时复制已经非常成熟vfork 的存在感已经很低。第 8 章对 vfork 有一条毫不含糊的警告在 vfork 之后、exec 或 _exit 之前子进程不得修改任何非 volatile 局部变量也不得从调用 vfork 的函数直接返回。原因很简单这些修改会作用在父进程的地址空间上直接污染父进程的数据。在 Linux 上vfork 和 fork 的实现差异已经缩小但库函数语义上仍保留了这样的约束。我的建议是新代码一律使用 fork完全没必要碰 vfork。你用它省下的那点复制成本现代内核已经帮你省掉了。3.2 exec 六兄弟l/v、p/e 后缀如何记忆exec 系列有六个函数第 8 章花了不少篇幅讲它们的区别。这六个函数底层都是同一个系统调用 execve其余五个是库封装。区分它们靠后缀带 llist表示参数以变长列表传最后一个参数必须是空指针带 vvector表示参数以字符串数组传数组最后一个元素必须是 NULL带 pPATH表示像 shell 一样搜索 PATH 环境变量来找可执行文件不带 p 时路径名必须是完整路径或相对路径带 eenvironment表示可以显式传入新的环境变量数组。罗列如下int execl(const char *pathname, const char *arg0, ... /*, (char *)0 */); int execv(const char *pathname, char *const argv[]); int execle(const char *pathname, const char *arg0, ... /*, (char *)0, char *const envp[] */); int execve(const char *pathname, char *const argv[], char *const envp[]); int execlp(const char *filename, const char *arg0, ... /*, (char *)0 */); int execvp(const char *filename, char *const argv[]);实际使用时有几个细节要记住。第一参数列表必须以 (char *)0 或 NULL 结尾别漏。第二argv[0] 可以随便给它只影响新程序里看到的 argv[0]但不影响 exec 实际去加载哪个文件。比如用 execl(/bin/sh, sh, -c, ls, (char *)0) 是标准用法。exec 成功之后没有返回值因为原来的进程已经被替换下一行代码根本不会再执行。只有失败时才会继续往下走这时要判断错误并处理。3.3 exec 之后的环境、描述符与解释器文件如果不使用 execle / execve 显式传环境数组exec 会把当前进程的环境变量数组继承给新程序也就是全局变量 environ 的内容。文件描述符默认是跨 exec 保留的除非设置 FD_CLOEXEC。这经常引发安全问题比如程序打开了高权限的文件随后 exec 一个外部命令外部命令可能继承了那个 fd从而拿到不该拿的数据。所以凡是 exec 之后还要用到的 fd要顺手设置 FD_CLOEXEC这是一种防御性写法。第 8 章专门讨论了解释器文件也就是我们常说的 shell 脚本和 shebang 行。内核加载一个以 #! 开头的文件时会读取第一行把解释器路径和可选参数带上。比如文件第一行写#!/bin/sh内核就会 exec /bin/sh并且把当前脚本文件的路径作为参数传给 /bin/sh。这行本质上是内核级支持的多进程编程入口很多运维脚本的行为模式都从这里延伸出来。注意如果解释器脚本本身没有可执行权限exec 会失败但如果脚本的 shebang 行指向的解释器存在且可执行脚本文件本身只需要读权限加执行权限即可。4. 退出路径exit、_exit 与 atexit 的来龙去脉4.1 exit 和 _exit 的真正区别很多人分不清 exit 和 _exit其实差异非常明确exit 是标准 C 库函数会先执行一系列清理动作再进入内核终止进程_exit 是系统调用直接终止进程不做任何标准 I/O 清理。exit 的清理动作包括调用所有通过 atexit 注册的退出处理函数刷新所有未关闭的 stdio 缓冲区关闭标准 I/O 流把退出码传给内核作为进程终止状态的一部分。_exit 不带这些花花肠子拿到退出码就直接进内核。所以当你在一个非常底层的子进程里只想干净利落地退出不想跑父进程注册的那些清理函数就该用 _exit。进程终止状态还有一个细节传给 exit / _exit 的整数参数只有低 8 位对内核对终止状态有影响。你传 1000实际状态值会是 232这个在跨平台移植时要留意。4.2 atexit 的倒序执行atexit 是给进程注册“临终动作”的函数。第 8 章强调过多个退出处理函数的执行顺序是注册的逆序也就是后注册的先执行。利用这个特性可以实现资源清理的层次化先加载底层模块再注册底层清理后加载上层模块再注册上层清理。进程退出时上层清理先跑底层清理后跑正好是依赖的反向顺序。下面这段#include stdio.h #include stdlib.h static void bye1(void) { printf(bye1\n); } static void bye2(void) { printf(bye2\n); } int main(void) { atexit(bye1); atexit(bye2); return 0; }运行结果永远是 bye2 先打印bye1 后打印。4.3 子进程不该直接 exit 的工程原因fork 之后子进程拥有父进程地址空间的完整副本包括 stdio 的缓冲区和 atexit 注册表。如果子进程调用 exit它会把父进程缓冲的数据再刷新一遍也会把父进程注册的清理函数再执行一遍。这是一个非常实用的结论子进程里优先使用 _exit 而不是 exit。除非你明确知道子进程需要刷新自己的 stdio 缓冲区否则只管 _exit。比如父进程写了一半日志到缓冲区fork 后子进程调用 exit缓冲区被清空日志被提前刷出去看起来父进程的数据也“正常”了但多出来的清理动作可能会让日志错乱。实际工程里很多疑难重复日志问题就是这么来的。5. 僵尸进程与 wait 家族怎样优雅回收子进程5.1 僵尸是怎么来的子进程先于父进程终止时内核并不会立刻把进程表项清空而是保留一个包含 PID、终止状态、CPU 使用时间等信息的记录等待父进程来接收状态。这个记录就是僵尸进程。僵尸进程无法通过 kill -9 干掉因为它已经死了。如果父进程一直不调用 wait / waitpid这个僵尸会一直占着 PID 位置。长期积累会让进程表变大甚至导致 fork 因资源限制而失败。而父进程先于子进程退出时子进程被 init/systemd 收养。等子进程退出init 会对它做 wait僵尸也就被清掉了。这也是系统设计里“根父进程兜底”的机制。5.2 wait vs waitpid参数与选项wait 的历史比较简单它只做一件事阻塞自己直到某个子进程终止。如果父进程一个子进程都没有直接返回 -1 并设置 errno 为 ECHILD。waitpid 是增强版pid_t waitpid(pid_t pid, int *status, int options);几个参数含义pid 大于 0只等待这个指定子进程pid 等于 -1等待任意一个子进程pid 等于 0等待进程组内任意子进程pid 小于 -1等待指定进程组内任意子进程。options 参数最常用的是 WNOHANG意思是“如果没有终止的子进程不要阻塞立刻返回 0”。这个选项在循环里非常关键可以让父进程去做别的事情同时隔一段时间来查一次账。还有 WUNTRACED 和 WCONTINUED分别用来获取“子进程被暂停”和“子进程被恢复执行”的状态汇报。调试器、任务控制程序经常用这两项。5.3 状态宏和终止状态wait 写入的 status 不是普通退出码而是一个编码后的 int。不要直接拿 status 去跟 0 比较必须用宏解析。常用宏如下宏用途WIFEXITED(status)是否正常退出WEXITSTATUS(status)正常退出时的退出码仅当 WIFEXITED 为真WIFSIGNALED(status)是否被信号终止WTERMSIG(status)终止该进程的信号编号仅当 WIFSIGNALED 为真WCOREDUMP(status)是否产生了核心转储配合 WIFSIGNALED 用WIFSTOPPED(status)是否处于暂停状态WSTOPSIG(status)导致暂停的信号编号WIFCONTINUED(status)是否被 SIGCONT 恢复执行一个典型的处理片段int status; pid_t pid waitpid(child_pid, status, 0); if (pid 0) { if (WIFEXITED(status)) { printf(exit code %d\n, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf(signal %d\n, WTERMSIG(status)); } }5.4 SIGCHLD、忽略与自动回收子进程终止时内核会向父进程发送 SIGCHLD 信号。默认动作是忽略但这也意味着如果父进程不 wait僵尸就被留下来了。主流工程做法是父进程注册一个 SIGCHLD 的信号处理函数在函数里循环调用 waitpid(-1, status, WNOHANG)把能够回收的子进程全部回收掉void sigchld_handler(int signo) { int status; while (waitpid(-1, status, WNOHANG) 0) ; }这里有两个约定俗成的细节一是回调函数里要加 while 而不是 if因为同一时间可能多个子进程同时变成僵尸一个 wait 只能回收一个二是要加 WNOHANG防止信号处理函数被阻塞在 wait 上导致主流程停摆。还有一个特殊机制在 Linux 下如果父进程把 SIGCHLD 显式设置为 SIG_IGN内核会自动回收子进程不产生僵尸。这看起来省事但代价是你再也拿不到子进程的终止状态。适合那种“子进程只是跑个任务结果我不关心”的场景比如后台批量任务。6. fork 之后的竞态与经典同步方案6.1 不确定的执行顺序第 8 章在讲完 wait 之后专门拿一节讲竞态条件意思是 fork 之后父子进程的执行顺序不确定如果它们之间互相依赖就很容易出现随机性崩溃。书中的例子很直观父进程 fork 一个子进程子进程要读取一个由父进程创建的文件父进程随后要去修改这个文件。如果你不在两者之间加同步那么子进程可能先跑、文件还没创建它就会读失败也可能父进程先跑完了子进程才读取结果成功。同样的代码每次结果不一样这种问题在线上最难排查。6.2 TELL/WAIT 管道同步的完整套路书里给出的经典方案是用管道做同步原语。核心思想是每个方向一个管道父进程往管道写一个字节通知“我干完了”子进程阻塞读管道直到读到对方写进来的内容。一个简单的实现思路如下static int pfd1[2], pfd2[2]; void TELL_CHILD(void) { if (write(pfd1[1], c, 1) ! 1) perror(write); } void WAIT_CHILD(void) { read(pfd2[0], c, 1); }WAIT_PARENT 对应读 pfd1[0]TELL_PARENT 对应写 pfd2[1]。这样父进程和子进程各自阻塞在自己的读端完成一次明确的握手。这个方案的优点是管道是确定性的read 没有读到字节前就是阻塞语义不会被信号抢先也不需要额外的全局变量。缺点是代码量多需要维护两个管道。不过在理解同步模型时它比信号更直观。6.3 现实之中的更轻量替代如果只是让子进程扮演“先跑的那个人”完全不关心父进程在干什么可以直接在 fork 后让父子进程各自执行不同分支即可。但如果两者之间有明确的先后依赖还是建议用管道或共享内存加原子标志。现代 Linux 也常用futex或C11 atomic_flag做自旋等待不过这些方法在早年的 APUE 语境里不存在。读书笔记可以加入一句现代视角能用管道就先用管道它最简单也不用担心锁的死锁问题。管道在进程间同步是经过深度检验的经典方案时至今日仍然可靠。7. 切换身份真实、有效与保持的用户 ID7.1 为什么需要三种 ID一个进程可能有三个用户 ID 相关概念真实用户 IDreal user ID启动这个进程的用户决定了这个进程是谁的有效用户 IDeffective user ID当前生效权限访问文件、操作资源时用的是它保存的设置用户 IDsaved set-user-ID执行文件时如果开启了 set-user-ID 位用它保存原始有效 ID以便以后能切换回来。最典型场景是 passwd 命令真实 ID 是普通用户有效 ID 却临时变成了 root因为要修改 /etc/shadow。改完之后程序又会把有效 ID 降回普通用户。三种 ID 的存在是为了让程序在必要时提权又能在最小化权限原则下把不需要的权限及时丢掉。7.2 setuid、seteuid、setreuid 的具体区别第 8 章把这几个接口分开讲是为了避免混用踩坑。setuid(uid)如果进程有特权会把真实、有效、保存三个 ID 同时改成该值如果进程没有特权只能把有效 ID 改成真实 ID 或保存 ID。这个函数在非特权进程里不是“万能改”只能往低权限方向走或恢复到保存的权限。seteuid(uid)只修改有效 ID。在特权进程里可以任意设置在非特权进程里只允许设置成真实 ID 或保存 ID。setreuid(ruid, euid)可以独立设置真实 ID 和有效 ID非特权进程也有一些交叉置换规则适合“先降权限、再升回来”的组合操作。在实际编码中我强烈建议用最小改动原则能不改就不改。如果你的子进程只是短暂需要某个特权获取任务后马上用 seteuid 降回来别在一个函数里把三个 ID 全改得面目全非。7.3 最容易犯的权限提升错误一个常见错误程序启动时持有 root 权限为了安全性调用 setuid(getuid()) 永久降权。降权之后又因为某个功能需要读关键文件于是调用 seteuid(0)。在 Linux 下如果 saved set-user-ID 没有保留 0这个调用就失败导致程序后续逻辑崩掉。所以降权要设计好时机是永久降权还是先降后升再降如果是先降后升就必须保证 saved set-user-ID 保留必要权限否则升不回来。另一个常见问题是 fork 之后子进程的权限需要自己设置一遍因为有效 ID 和真实 ID 是继承的但子进程如果执行了 execexec 会重新读取可执行文件的 setuid 位很可能改变权限状态。8. system 函数与进程记账把第 8 章当工具箱看8.1 system 就是 forkexecwait 的教科书实现system 函数在标准库里并不起眼但它的实现逻辑恰好把这一章的核心串起来了fork 一个子进程在子进程里 execl 执行 /bin/sh -c command父进程阻塞在 waitpid 上等子进程结束。这意味着调用 system 期间父进程做不了别的事如果父进程自己有信号处理逻辑还需要处理 SIGINT 和 SIGQUIT 的屏蔽问题。第 8 章提醒我们system 内部会临时将 SIGINT 和 SIGQUIT 设置为忽略并在 waitpid 前阻塞 SIGCHLD以防止信号干扰等待逻辑。所以凡是要求高可靠性的服务谨慎使用 system。很多生产事故来自于在服务代码里调 system(curl xxx)命令失败、输出混乱、信号互相干扰最后很难排查。8.2 进程记账内核收银台第 8 章后面讲了进程记账process accounting这是一个平时感知不到、关键时刻很好用的功能。当进程终止时内核会在记账文件里写一条记录包括用户 ID、组 ID、启动命令名、CPU 时间、内存占用等。开启记账的方式一般是调用 acct 函数。它不是性能开销很高但默认不开启因为每条进程终态都要写盘。排查“这个进程到底是被谁启动的”时可以去看进程记账数据信息比日志更接近内核视角。9. 读完本章之后常踩的坑三个具体案例速查9.1 守护进程里 SIGCHLD 回收不彻底有一回排查一个常驻服务发现 /proc 里堆了几十个 defunct 进程服务本身很稳定但子进程一次次变僵尸。查到最后就是父进程只 wait 了一个指定子进程没处理其他子进程的通知。改成信号处理函数里 while 循环 waitpid(-1, ..., WNOHANG) 僵尸立刻清干净。这个经验也顺带说明实时查看僵尸进程最简单的命令是 ps -el | grep defunct或者看 /proc 下的状态标记。看到 Z 状态说明父进程没做好收尾。9.2 “stdout 缺了几行”原来是缓冲区问题有一次写一个小工具主进程 fork 了三个子进程主进程先打印了一些日志然后子进程各自去干活。执行时日志全部正常一旦把标准输出重定向到文件日志就出现重复片段。这就是前面说的 stdio 缓冲区问题。解决方式是在 fork 前调用 fflush(NULL)并且让子进程不要在最后一个动作时调用 exit而是调用 _exit。记住fork 之前把该刷的缓冲区刷掉子进程的 exit 路线最好绕开父进程的清理逻辑。9.3 权限切换后无法回到 root 的实际场景一个小程序启动时是 root完成配置读取后主动 setuid 降权。某次改需求又增加了动态加载插件的动作插件里尝试打开特权文件直接失败。根因是代码里用 setuid(getuid()) 把真实、有效、保存三个 ID 都改成了普通用户理论上已经无法恢复 root。后来改成先在配置阶段保留 saved set-user-ID再在需要时设置有效 ID操作完立即降回来问题消失。这段经历让我一直记得永久降权要谨慎临时降权才用 seteuid。9.4 快速问题对照表现象可能原因解决方向子进程输出数据重复fork 前 stdio 缓冲区未清空子进程中使用 _exitfork 前 fflush(NULL)大量 defunct 进程父进程没有 wait 或 wait 次数少SIGCHLD 处理器中循环 waitpidfork 返回 -1 且 errno 为 EAGAIN达到进程数限制检查 RLIMIT_NPROC减少并发子进程exec 之后新程序拿到多余 fd未设置 FD_CLOEXEC打开 fd 时设置 FD_CLOEXEC子进程正常退出但状态不对使用 exit 而非 _exit或 status 未解析用 WIFEXITED / WEXITSTATUS 解析子进程偶发读不到父进程刚写的文件fork 后未做同步用管道或信号机制做 TELL/WAIT说到底fork 并不难难的是在每个进程退场之前确认它有没有把自己的责任区段处理干净。我在写多进程代码时会条件反射地检查三件事fork 前缓冲区有没有刷干净子进程用的是不是 _exit父进程有没有在任意一种路径上 wait。这三件事做扎实这本书第八章的相当一部分内容就已经转化成了你的工程直觉。