UNIX进程控制精讲:fork、exec与守护进程的生命周期解析
说句实话《UNIX高级环境编程》这本书我每隔一两年就会翻一遍每次翻到第八章“进程控制”都会有新的体会。这一章讲的是 fork、exec、exit、wait 这些最基础的系统调用但恰恰是这些基础函数构成了 UNIX 下几乎所有多进程程序的骨架。你写的每一个 Web 服务器、任务调度器、容器里的 init 进程底层都在反复使用这套机制。很多自学 UNIX 编程的人会有这种体验前面几章文件 IO、目录操作读得津津有味一到第八章就有点懵不是文字难懂而是信息密度实在太高——fork、exit、wait、exec、进程组、会话、守护进程每个概念单独拎出来都能写一篇长文它们又互相串在一起。这篇读书笔记我打算以“进程从哪来、怎么活、怎么死、怎么换”为主线把第八章的要点拆开揉碎再补一些我做多进程服务时踩过的坑。如果你是刚接触多进程编程的读者这一章是你从“写单进程小程序”走向“真正系统级开发”的必经之路。如果你已经有几年经验再看这一章也往往会有“原来当年那个 bug 是这么回事”的顿悟。1. 第八章的分量为什么说进程控制是多进程编程的地基1.1 一句话概括这一章在做什么这一章的核心就是完整描述一个进程的生命周期创建fork、执行新程序exec、退出exit/_exit、被父进程回收wait/waitpid。再往外延伸还讲了多个进程如何组织成进程组、会话以及如何利用这些机制写出真正后台运行的守护进程。很多人觉得多进程编程难难在“脑子里同时有四五个进程在跑”。但其实你只要抓住一条主线每个进程都有出生、存活、死亡三个阶段UNIX 用系统调用把三个阶段全部标准化了。第八章就是对这些标准动作的逐一拆解。1.2 它和前后章节的关系这本书前半部分讲文件 IO、目录操作、进程环境都是在给这一章铺路。比如进程环境章讲到的全局变量、环境变量、栈布局到了 fork 这里马上就会用到fork 会复制整个进程地址空间环境变量也随之复制。后面讲信号、终端 IO、多线程时又反过来依赖这一章的进程组织概念。你可以把第八章理解为整个书的“腰部”它把前面讲的所有资源文件描述符、内存布局、环境变量串在了一个全新维度上时间维度。进程不是静止的而是会创建、切换、替换、消亡的。1.3 读这一章之前最好先有这几个基础概念进程 IDPID每个进程唯一标识getpid()获取PID 0 是调度进程PID 1 通常是 init 进程。进程的内存布局正文段、数据段、堆、栈。fork 后父子进程的内存内容相同但物理内存可能共享。文件描述符fork 会复制文件描述符表但底层文件表项是共享的这里非常容易踩坑。函数栈帧fork 返回时父子进程各自从同一个栈帧继续执行但返回值不同。如果你对这些概念还模糊建议先把第三章“进程环境”和第四章“文件和目录”扫一遍。不然后面有些代码看起来会觉得莫名奇妙。2. 进程创建fork 之后的世界2.1 fork 函数本身不神秘但返回值设计得很精妙fork 的原型只有一行pid_t fork(void);但它可能是整个 UNIX 里让人困惑的系统调用第一名调用一次返回两次。父进程返回子进程的 PID子进程返回 0出错时返回 -1。为什么这样设计我们站在编写者的角度想一想子进程需要知道自己是被 fork 出来的返回 0 是给子进程的“暗号”。父进程必须知道子进程的 PID因为后面 wait 回收时要指定对象。如果 fork 失败父进程需要能感知到所以返回 -1。这个返回值设计帮助我们在同一份代码里区分父子身份。最常见的模板是pid_t pid fork(); if (pid 0) { perror(fork error); exit(1); } else if (pid 0) { // 子进程 } else { // 父进程 }注意这里的“分支”不是两路同时执行而是两个独立的进程从 fork 返回后各自执行一段代码。父子进程的执行顺序没有保证谁先跑完全看内核调度。这也是多进程程序第一个让人不适的地方你以为的顺序不一定真的发生。2.2 文件描述符表是复制但文件表项是共享的fork 会复制父进程的文件描述符表这一点很多人知道。但真正关键的是复制后的描述符指向同一个文件表项也就是说文件偏移量是共享的。我用一个实际例子说明坑在哪。假设父进程已经打开了一个日志文件然后用 fork 创建子进程。如果父子进程都往里写日志FILE *log fopen(app.log, a); pid_t pid fork(); if (pid 0) { fprintf(log, child log line\n); } else { fprintf(log, parent log line\n); }由于文件偏移量共享两条日志会按实际写入顺序追加不会覆盖。这看起来是好事但如果是两个进程写同一个 socket、同一个管道、同一份数据库文件就会出现交错数据。所以多人协作的项目里我一般建议 fork 之后尽快把不需要的描述符关掉或者用锁、信号量来做同步。另外要注意buffer 也是“复制品”。如果你用的是标准 IO 的FILE *fork 前已经写入缓冲区但还没 flush那么这个缓冲区会被完整复制给子进程。结果就是同一份内容可能被输出两次而父进程也可能丢掉缓冲数据。2.3 子进程到底继承了哪些东西第八章没有列出一个“继承清单”但整理一下非常有用。子进程会从父进程继承环境变量、打开的文件描述符、信号处理设置但捕获的信号处理函数会被重置、当前工作目录、根目录、umask、控制终端等。不会继承的包括父进程的进程 ID、父进程自身的线程 ID、父进程设置的记录锁以及alarm和setitimer产生的闹钟剩余时间。有一个细节我在实战中经常用到父进程如果设置了忽略某个信号fork 后子进程也会忽略这个信号。这在写守护进程时特别有用比如忽略了 SIGPIPE 之后再 fork子进程也不会因为写关闭的 socket 而突然死掉。2.4 写时复制COW到底怎么回事能当多线程用吗早期 UNIX 的 fork 就是完整复制父进程地址空间简单但慢。现代内核几乎都实现了写时复制Copy-On-Writefork 时只复制父进程的页表并把所有页标记为只读。父子进程中任何一方尝试写入某个页才会触发缺页异常内核才真正复制这一个页。所以现在的 fork 成本比想象的低很多很多程序大规模 fork 也没问题。但千万别因此把 fork 当成“轻量级线程”来用。因为 fork 构造的是两个独立地址空间父子进程之间的全局变量是各有一份不是共享的。你想用 fork 做并发计数器改一个全局变量另一进程完全看不到变化。真正的内存共享得靠 mmap 或者 System V / POSIX 共享内存。3. 进程退出与资源回收别让你的进程变僵尸3.1 exit、_exit 和 return 的关系进程退出有两种方式正常退出和异常退出。正常退出可以调用exit或_exit也可以在 main 里 return异常退出是收到信号或调用 abort。关键区别在于exit是 C 库函数会先执行钩子函数atexit注册的清理函数、刷新标准 IO 缓冲区最后调用_exit进内核。_exit是系统调用直接终止进程不刷新缓冲区也不执行清理钩子。下面这个例子我每次都要讲因为太容易踩了#include stdio.h #include unistd.h int main(void) { printf(hello without newline); _exit(0); }运行后你会发现什么都没输出。原因就是printf的数据只进了缓冲区_exit直接就切断了进程缓冲区内容没来得及 flush。而如果你用exit(0)标准 IO 会先冲刷缓冲区输出就能看到。所以记住一句话普通代码里用 exit只有在特殊场景比如 exec 失败后想立刻终止、且不希望 flush 脏数据才用 _exit。3.2 终止状态怎么传给父进程进程退出时内核会保存一个终止状态。exit(0)传 0exit(3)传 3但实际只有低 8 位有意义。父进程可以用 wait 族函数拿到这个状态再用宏解析WIFEXITED(status)是否正常退出WEXITSTATUS(status)正常退出时拿退出码WIFSIGNALED(status)是否被信号终止WTERMSIG(status)具体是哪个信号这是多进程协作里非常重要的“结果反馈机制”。父子进程是分离的子进程干得怎么样必须通过终止状态告诉父进程。3.3 僵尸进程与 SIGCHLD一个进程终止后如果父进程没有及时 wait内核会保留该进程的进程表项包含 PID、终止状态、CPU 时间等。这个状态的进程叫“僵尸进程”。它已经不执行任何代码不能被 kill甚至不能“销毁”它必须等父进程回收。如果你写了这样的代码while (1) { pid fork(); if (pid 0) exit(0); }很快就满屏幕的僵尸。原因简单子进程一个个退出了父进程从来没 wait。处理方式有三种父进程阻塞在 wait 或 waitpid 上。非阻塞轮询回收。捕获 SIGCHLD 信号在信号处理函数里 wait。其中第三种是生产环境比较常见的方式。不过 SIGCHLD 处理有个老知识点UNIX 系统不会对 SIGCHLD 排队如果多个子进程同时退出处理函数可能只执行一次。所以信号处理函数里必须用waitpid(-1, ...)配合WNOHANG循环回收直到返回 0 或 -1。3.4 wait 和 waitpid怎么选这两个函数的区别可以用一个小表概括特性waitwaitpid默认等待对象任意子进程可指定单个/任意是否阻塞一定阻塞到有子进程终止可通过选项设为非阻塞是否能等被停止的子进程不可以可以用 WUNTRACED / WCONTINUED是否有 wait3/wait4 的资源统计无wait4 有wait(status)相当于waitpid(-1, status, 0)。但实际项目中我更推荐统一用 waitpid因为你可以指定 PID避免回收错了孩子。有个经典问题waitpid 需要指定等待“同一个父进程的子进程”不是任意进程。如果你 fork 了多个子进程想让第一个退出的先被回收用waitpid(-1, ...)。还有一点UNIX 的 wait 可能被信号打断返回 -1errno 是 EINTR。这时候不要直接报错应该重新调用 wait或者接受这个打断。写循环时尤其要记住while ((pid waitpid(-1, status, 0)) -1) { if (errno EINTR) continue; perror(waitpid); break; }3.5 非阻塞回收用 WNOHANG在游戏服务器、网关这类需要同时管理几千个通道的程序里你不可能长期阻塞在 wait 上。常见做法是主循环定期非阻塞扫描while (1) { pid_t pid waitpid(-1, status, WNOHANG); if (pid 0) { // 成功回收一个子进程 } else if (pid 0) { // 还有子进程在运行 break; } else { if (errno ECHILD) { // 没有子进程了 } break; } }WNOHANG 只是“有就回收没有立刻返回”。用它做定时清理就不会让僵尸堆积。4. 程序替换exec 族让你把当前进程变成另一个程序4.1 fork 之后为什么要 execfork 会复制当前进程但很多时候我们要的是一个全新程序比如 shell 里输入lsshell fork 出一个子进程然后这个子进程立刻 exec 成/usr/bin/ls。子进程还是那个 PID但地址空间、代码段、数据段全换掉了。这就是进程的“变身术”。exec 族共有六个函数函数参数形式搜索 PATH可传环境变量是系统调用execl列表否继承否execv数组否继承否execlp列表是继承否execvp数组是继承否execle列表否指定环境否execve数组否指定环境是字母含义是这样的l表示每个参数用独立参数传入最后以 NULL 结尾v表示参数放在字符指针数组里p表示使用 PATH 环境变量搜索可执行文件e表示手动指定环境变量。实际系统调用只有execve其他都是包装函数。你几乎可以只用execvp或execve完成所有需求。4.2 exec 成功不返回失败才返回 -1这点太重要了exec 成功时会把新程序加载进当前进程原来的代码立即被覆盖所以不可能再回到 exec 调用的下一行。这就是“成功没有返回”的原因。失败时它才返回 -1并设置 errno。这带来一个非常实用的写法exec 后面不要接着写任何业务逻辑只要写错误处理。execlp(ls, ls, -l, NULL); // 如果走到这里exec 一定失败了 perror(execlp failed); _exit(127);127这个退出码是 shell 常用的“命令未找到”约定。exec 失败后文件描述符和缓冲状态也很关键stdio缓冲区不会被冲刷所以如果你 exec 前printf但没换行可能会出现内容丢失。4.3 打开的文件描述符默认保留除非设置 FD_CLOEXECexec 成功后原进程打开的文件描述符默认仍然保留除非在 open 时指定了O_CLOEXEC或者用fcntl设置了FD_CLOEXEC标志。这是安全的一环如果不小心在 exec 前多开了文件新程序就会拿到不该访问的描述符。我的习惯是 fork 前先写好“想留什么描述符”然后在子进程 exec 前把多余描述符全部显式 close。宁可多关几行也不要让新程序继承一口没必要的数据库连接、日志文件句柄、socket。4.4 forkexec 标准模板综合前面的知识点一个干净的子进程启动模板大致是这样的pid_t pid fork(); if (pid 0) { perror(fork failed); exit(1); } if (pid 0) { // 子进程清理不需要的描述符 close(unused_fd); // 设置必要环境 setenv(MY_PROCESS, worker, 1); // 执行新程序 execl(/usr/bin/python3, python3, worker.py, NULL); // 到这里说明 exec 失败 perror(execl failed); _exit(127); } // 父进程 int status; waitpid(pid, status, 0);这个模板对应了绝大多数“启动子任务”的场景。注意 exec 前不要再用标准 IO 写数据因为 exec 不会刷缓冲区一旦丢失不太好排查。5. 进程组、会话和控制终端进程也有“组织架构”5.1 为什么会有进程组和会话多进程程序不是一堆孤零零的 PID 散落着。UNIX 把进程组织成三层进程、进程组、会话。进程组是一个或多个进程的集合同组的进程可以接收同一个终端信号。会话是一个或多个进程组的集合会话通常和一个控制终端关联。控制终端是会话与用户交互的入口比如你在终端里按 CtrlC内核会向前台进程组的所有进程发送 SIGINT。第八章介绍了getpgrp、setpgid、getsid、setsid这几个函数。理解它们的关键是“信号投递单位”你按下 CtrlC不是发给某个进程而是发给整个前台进程组。这能解释为什么管道cat file | grep txt里的两个进程都能被 CtrlC 杀掉——它们同处一个前台进程组。5.2 setpgid 的实际用例setpgid(pid, pgid)可以把指定进程放到指定进程组里。最常见的用例是 shell 启动后台作业pid fork(); if (pid 0) { // 子进程立即把自己放进新进程组防止父进程 cgroup 冲突 setpgid(0, 0); execvp(argv[0], argv); } // 父进程也必须设置避免父进程先设置时子进程还没 set 的竞态 setpgid(pid, pid);为什么父子两边都要调 setpgid因为 fork 后执行顺序不确定。如果父进程先给子进程设进程组但子进程还没 exec没问题但如果子进程先执行就必须让子进程自己调 setpgid。两边设同一个 pgid结果一样但能避免“父进程设置了、子进程又 reset”的竞态窗口。很多网络服务也在 fork 后用 setpgid/ setsid 实现脱离终端的控制。5.3 setsid创建新会话的钥匙setsid()更厉害调用成功后调用进程成为新会话首进程。调用进程成为新进程组组长。调用进程失去控制终端。前提条件是调用进程不能已经是进程组组长。这解释了为什么守护进程初始化的第一步通常是 fork 一次因为父进程往往是进程组组长子进程不是组长只有子进程才能成功 setsid。进程组与会话这一节看起来抽象但它是理解守护进程、作业控制、终端信号的基础。很多读了一遍这章的人到写守护进程时才回头翻明白。6. 守护进程进程控制知识的综合应用6.1 守护进程和普通后台程序的区别后台程序并不等于守护进程。你在这个 shell 里跑./server 它确实转入了后台但还在当前会话、与当前终端关联CtrlC 可能把它杀掉退出 shell 也可能让它挂掉。真正的守护进程是“脱离终端、脱离会话、自主运行”的后台进程比如 crond、sshd、nginx。第八章把守护进程的初始化步骤按逻辑拆成几步。每步都有明确目的不是网上流传的“八股文”。6.2 守护进程的标准初始化步骤fork 一次并让父进程退出。目的是让子进程成为孤儿进程并被 init 收养同时让子进程不是进程组组长这样后面 setsid 才能成功。子进程调用 setsid。创建新会话失去控制终端成为新会话首进程和新进程组组长。再次 fork 一次并让中间父进程退出。这一步的目的是确保调用进程不是会话首进程这样可以防止它重新获取控制终端。改变工作目录chdir(/)。避免挂载点占用也避免让文件系统无法正常卸载。设置 umask(0)。确保守护进程创建文件时不受父进程 umask 影响权限由自己显式控制。关闭不需要的文件描述符。最好遍历 0 到最大描述符数关闭所有无关 fd。把标准输入、标准输出、标准错误重定向到 /dev/null。或者也可以重定向到日志文件取决于程序设计。其中两次 fork 的细节值得多说一句。第一次 fork 后 setsid 已经能脱离控制终端了为什么还要第二次 fork因为会话首进程如果以后再次打开一个终端设备可能会意外获得该终端作为控制终端。再次 fork 让中间父进程退出让最终子进程不是会话首进程就避免了这种风险。现代 Linux 上控制终端的行为和传统 System V 不一定完全一样但按标准写基本不会错。6.3 一个最小守护进程模板void daemonize(void) { pid_t pid fork(); if (pid 0) exit(1); if (pid 0) exit(0); // 父进程退出 setsid(); // 新会话 signal(SIGHUP, SIG_IGN); // 忽略挂断信号Linux上通常配合二次fork pid fork(); // 二次fork if (pid 0) exit(1); if (pid 0) exit(0); chdir(/); umask(0); int fd; for (fd 0; fd 1024; fd) close(fd); open(/dev/null, O_RDONLY); open(/dev/null, O_WRONLY); open(/dev/null, O_WRONLY); }这个模板把前面几节知识全部用上了fork、_exit、setsid、信号、文件描述符。如果你只看代码不看书会觉得每一行都是魔法但对照第八章原理每一步都能说清动机。7. 常见问题速查表、实操心得与避坑清单7.1 常见问题速查表问题现象可能原因排查方向子进程 printf 内容丢失缓冲区没冲刷或用了 _exitexec 前 fflush(NULL)普通退出用 exit子进程退出后系统里有僵尸父进程没 wait 或 wait 信号处理没循环用 waitpid(-1, WNOHANG) 循环清理fork 后父子进程互相干扰全局变量进程地址空间独立用共享内存或改为多线程父子和子进程同时写日志文件出现乱码文件偏移量共享、无同步每行加锁或 fork 后尽快分离exec 后代码没有执行exec 成功就不会返回在 exec 下一行写错误处理waitpid 被信号打断返回 -1EINTR检查 errno 后重新调用守护进程启动后没几秒就退出缺少 setsid 或 umask 设置不当按标准初始化步骤逐步排查7.2 我踩过坑的几个点第一个坑是“fork 后的标准 IO 缓冲”。有一年我写一个批量任务分发程序父进程先读了一个配置配置库里用了stdin相关 fopenfork 子进程后子进程也读取同一个配置结果出现内容重复或错乱。后来总结fork 之前尽量少用标准 IO或者 fork 前先fflush(NULL)。第二个坑是“waitpid 只看返回值没检查 EINTR”。某个服务低峰期很正常高峰期频繁出现在回收子进程时卡住或者报错最后发现是信号打断了 waitpid循环里没有处理 EINTR。第三个坑是“守护进程二次 fork 时忽略了 SIGHUP”。在 Linux 上如果会话首进程收到 SIGHUP常常会直接退出。做守护进程时忽略 SIGHUP 是很常见的操作否则终端关闭后守护进程可能莫名其妙死掉。7.3 从笔记到实战几条建议读完第八章很多知识还是被动的。我建议你手头准备好一台 Linux 虚拟机或者容器亲自写几个小实验写一个程序fork 两个子进程各自打印自己的 PID 和父子 PID父进程等待回收观察打印顺序。fork 后不调用 wait让子进程退出用ps -ef看僵尸进程再写一个 SIGCHLD 处理函数回收观察僵尸消失。在终端里执行你的守护进程模板然后用ps -o pid,sid,pgrp,tpgid,cmd查看进程组、会话、控制终端的变化。用strace -f跟踪一个简单的shell执行命令的过程看 fork/exec/wait 系统调用的顺序。这些实验做完你对这一章的理解会远超看书本身。我个人在实际使用的过程中的体会是很多看似复杂的多进程问题最后都能归到这一章最朴素的几个函数上。真正写好 UNIX 下多进程程序不是靠记住 API而是理解进程的边界在哪内存边界、文件描述符边界、终端信号边界。第八章讲的就是这些边界。如果你正在读这本书不用急着一次性把代码例子全跑通先把“进程怎么生、怎么死、怎么换”这三件事的机制理清楚后面再去看信号、线程、IPC都会顺畅很多。