深入理解Linux进程退出、等待与替换机制
如果你学过几天 Linux 系统编程一定写过或看过这样的代码fork 出一个子进程然后在子进程里调用 exec 家族函数去跑另一个程序父进程再用 wait 等着收尸。但很多人写是写出来了心里其实没有完全搞清楚这三步各自在底层做了什么进程退出的时候退出码是从哪里带回来的父进程为什么必须 waitexec 明明把整个进程都换了PID 为什么不变这些问题的答案恰好就落在标题里这三个关键词上进程的退出、等待与替换。它们不是三个孤立的知识点而是一条完整的事件链。一个进程从被 fork 出来到 exec 换掉自己的影子再到最终退出并把自己的状态交给父进程整个过程环环相扣。搞懂这条链你就不会再被命令找不到返回 127、子进程变僵尸、printf 的输出凭空消失这类问题困住看系统编程的很多疑难杂症也会通透得多。这篇文章我打算从内核和 glibc 两个层面讲透这三个机制每一段都会配合代码和排查经验适合正在学 Linux 进程控制、写过 fork 但没完全吃透的读者也适合被僵尸进程和 exec 细节坑过、想系统补课的人。1. 退出状态从 main 函数 return 到内核回收中间经历了什么先从一个最常见的场景说起。你写了一个 C 程序main 函数最后写了一句 return 0编译运行后 echo $? 看到 0于是你觉得程序正常退出了。这句话只说对了一半。return 0 只是一个语言层面的动作真正让进程消失、让父进程拿到结果的是一个完整的链路这条链路上任何一个环节出了问题退出码都会变得不可信。1.1 return、exit、_exit 三种退出的真实分工main 函数里的 return N实质上不是进程的终点。C 语言的启动代码一般由 crt 目标文件提供会把 main 的返回值交给 exit(N) 来处理。这里要特别注意exit 是一个 C 库函数_exit 才是系统调用层的东西。很多人把这两个搞混实际上它们的分工完全不同。函数层次会运行 atexit 注册函数会刷新 stdio 缓冲区最终进入内核return Nmain 中C 语言语法间接会间接会通过 exit 间接进入exit(N)C 库函数会会通过 _exit 间接进入_exit(N) 或 _Exit(N)系统调用封装不会不会直接进入exit(N) 做的事情相当多先按注册顺序的逆序调用 atexit 注册的函数再清理 C 标准库自己的内部状态刷新所有还留在 stdio 用户态缓冲区里的数据最后才调用 _exit 进入内核。_exit 则是一个非常暴力的通道它不跑任何回调不刷缓冲区直接告诉内核这个进程到此结束。这就是为什么在子进程里执行程序失败后大家都建议用 _exit 而不是 exit 收尾。exit 会把父进程 fork 时拷贝过来的 stdio 缓冲区顺手刷一遍可能会刷出重复数据还会把父进程注册的 atexit 函数在子进程里也执行一次极易产生诡异现象。这一点后面排查部分会详细展开。1.2 内核眼中的退出exit_group 才是真正的大总管从系统调用层面看Linux 内核里最常看到的实际退出入口不是名字叫 exit 的某个函数而是 exit_group。exit_group 的设计意图很明确一个进程往往不止一个线程多线程情况下任何一个线程主动退出都应该让整个线程组全部结束否则会出现主线程没了其他线程还在跑的混乱局面。所以 glibc 的 _exit 封装在内核里大概率走的是 exit_group 系统调用。它做的主要事情包括把当前线程组里所有线程都终止释放这个进程占用的绝大部分内存关闭所有文件描述符释放页表、信号量等内核资源最后把退出状态记录在进程表项里等待父进程来取。进程的大部分知名度资源在这里被释放但进程表项不会立刻删掉因为父进程可能还要查它。关于退出状态内核只保留 8 位。也就是说你在 _exit(300) 里传一个 300内核实际记录的会被截断成 300 - 256 44。这就解释了为什么 shell 里经常看到退出码最大就是 255的说法。1.3 被信号杀死的进程128 信号编号是怎么来的进程退出不只有正常退出一条路。收到信号而被终止的情况和退出码完全是两回事。当进程被某个信号杀死wait 拿到的状态字里并不会存一个退出码而是会记录信号编号。比如进程因为 Segmentation Fault 退出等待方看到的是 SIGSEGV编号 11而不是某个退出码。但 Shell 的 $? 变量却经常显示 139、137 这类数值这是怎么回事这是 shell 故意把信号编号 128作为约定俗成的映射。130 128 2SIGINT137 128 9SIGKILL143 128 15SIGTERM。这个 128 偏移不是内核产生的是 shell 为了把正常退出和被信号杀死统一成一个数字而定的规则。你写脚本判断程序是不是被 SIGKILL 干掉时可以依赖这个约定但如果你在写 C 程序的父进程不要直接去看 128N应该用后面要讲的 WIFSIGNALED 和 WTERMSIG 宏。2. 等待时机wait/waitpid 如何拿到子进程的最终报告进程退出之后父进程如果不闻不问系统会出什么问题最典型的就是僵尸进程。我见过很多初学者第一次看到僵尸进程时非常困惑进程明明已经结束了ps 里还能看到它状态还是 Z。这个现象不奇怪恰恰说明内核在设计上有自己的坚持。2.1 内核为什么要保留僵尸进程父进程和子进程之间天然存在一种责任关系内核把子进程的退出状态交给父进程之前这段状态必须有人保管。子进程退出后内核不能直接把进程表项删掉否则父进程想查退出码时无据可查。于是内核选择把进程的大部分资源释放掉只保留进程表里最少量的信息——进程 PID、退出状态、占用 CPU 的时间统计等然后把这个状态标记为僵尸态。可以把这个机制理解成系统在你家门缝里塞了一张纸条你孩子的考试成绩出来了但你必须亲自来取。内核把纸条留着你什么时候来取都行但你一天不来取这张纸条就一天不会消失。在系统层面PID 是一种会被复用的有限资源僵尸进程占着 PID哪怕它并不吃内存如果量大到把 PID 号段占满新进程就 fork 不出来了。2.2 wait 与 waitpid 的选择等待的方式决定了程序的实时性wait 和 waitpid 都是用来取这张纸条的。它们的关系是wait 是最朴素的版本阻塞当前进程等任意一个子进程退出并把它的状态写进 int 变量。waitpid 则在 wait 之上给了三个精细控制维度等哪个子进程、是否阻塞、是否关心停止状态。waitpid 的第一个参数可以指定具体 PID也可以传 -1 表示任意子进程这个语义和 wait 很像。第二个参数是状态指针第三个参数最值得研究它决定了调用者的行为模式。option 值作用典型场景0阻塞等待目标子进程退出前台命令执行完再继续WNOHANG非阻塞没退出立即返回 0后台任务轮询WUNTRACED也返回刚被信号停止的子进程调试器需要感知 stopWCONTINUED也返回从停止中恢复的子进程作业控制场景很多服务端程序的主循环需要同时处理大量子进程如果用 wait 阻塞等任意一个逻辑上很难精确控制等哪一个。waitpid 配合 WNOHANG 做轮询才是常见做法。比如某个守护进程起了 10 个 worker主进程需要用 waitpid(-1, status, WNOHANG) 循环收割已经退出的 worker再启动新的补齐数量而不是傻等某一个 PID。2.3 status 状态字的位域解析别只看数字要用宏wait 家族函数返回的 int 不是普通整数它是个位域打包的复合状态。Linux 上的布局大致如下如果进程正常退出低 8 位为 0第 8 到 15 位保存退出码。如果进程被信号杀死低 7 位保存信号编号第 7 位表示是否产生了 core dump。如果进程是被停止或恢复第 8 到 15 位保存导致状态变化的信号编号。直接读这个整数一定会晕所以系统提供了一组宏专门用来拆解。宏含义用法WIFEXITED(status)是否正常退出返回真再取退出码WEXITSTATUS(status)获取正常退出码必须是 WIFEXITED 为真时使用WIFSIGNALED(status)是否被信号杀死返回真再查信号编号WTERMSIG(status)获取终止信号的编号必须是 WIFSIGNALED 为真时使用WCOREDUMP(status)是否产生 core dump配合 WIFSIGNALED 使用WIFSTOPPED(status)子进程是否处于停止状态配合 WUNTRACED 使用WSTOPSIG(status)获取导致停止的信号编号配合 WIFSTOPPED 使用WIFCONTINUED(status)是否从停止中恢复配合 WCONTINUED 使用这里要强调查一下使用顺序。我曾经见过一段代码不检查 WIFEXITED 就直接拿 WEXITSTATUS 的结果做业务判断结果子进程是被 SIGKILL 掉的WEXITSTATUS 返回的却是那段位域里无关紧要的零碎数据程序误判子进程正常返回 0整个任务状态全乱。正确姿势永远是先问 WIFEXITED是正常退出才取退出码再看 WIFSIGNALED被信号杀的就取 WTERMSIG。3. 僵尸进程的治理从被动 wait 到主动忽略、双重 fork僵尸进程这个概念的坑很多人在没写过高并发父进程程序时根本遇不到。单进程 fork 一个子进程然后 wait 等它退完全没问题。但如果你写的是需要长期运行、频繁创建子进程的网络服务程序收割不及时僵尸就会像堆垃圾一样越积越多。这一节讲的是怎么让收尸这件事变得可控。3.1 僵尸进程的危害到底有多大先说结论单个僵尸进程本身几乎不消耗 CPU 和内存这常常让人掉以轻心。它的真实危害有两个第一是进程表项无法释放PID 被占着不能复用。Linux 默认的 PID 上限通常在几万左右一旦僵尸数量逼近上限系统就无法创建新进程表现为 fork 返回 EAGAIN。第二个危害是排查问题时非常混淆视听ps 里一屏 Z 状态进程你很难立刻分清哪些是自然老化、哪些是程序逻辑缺陷造成的。还有一个反向的迷惑性父进程如果先于子进程退出子进程会被内核自动过继给 1 号进程通常指 init 或 systemd由它来负责 wait 收割。这时子进程退出后不会变成无人认领的僵尸最多短暂存在一下就被系统回收。所以孤儿进程并不危险真正危险的是爷爷长寿、孙子短命、而爷爷永远不 wait。3.2 三种防僵尸方案与实际选型第一种最笨也最稳普通 wait / waitpid 同步等待。子进程执行时间短父进程本来就要阻塞等它跑完就直接等着收。缺点是父进程在这段期间什么都干不了适合简单脚本和同步工具。第二种是信号驱动给 SIGCHLD 设置信号处理函数在 handler 里循环调用 waitpid(-1, status, WNOHANG)直到返回 0 或 -1 为止。SIGCHLD 会在子进程状态变化时自动发给父进程所以父进程不需要阻塞等待平时该干嘛干嘛收到信号再集中收割。这里有个容易犯的错handler 里只调用一次 waitpid 是不行的因为同一时刻可能多个子进程一起退出而 SIGCHLD 信号并不会排队你收到一个信号时可能已经积压了三四个子进程的退出事件必须用循环把这一轮能收割的全部收割掉。第三种是双重 fork。思路是父进程 fork 出一个中间子进程中间子进程马上再 fork 出实际干活的孙进程然后中间子进程立即 _exit(0)。父进程只需要 wait 中间子进程中间子进程一退孙进程就被内核过继给 1 号进程接管最后孙进程退出时由 1 号进程统一 wait父进程完全不用管。这个方案特别适合父进程不想阻塞也不想写信号处理函数的场景代价是多了一次 fork 的系统开销。需要注意双重 fork 里中间子进程的职责只是快递一下父进程关系本身不能做业务逻辑。如果你在中间子进程里又去执行业务代码整个设计就变味了。在实际服务端开发中用得最多的是 SIGCHLD 加循环 waitpid它能在不阻塞主流程的前提下把僵尸问题处理得比较干净。4. 进程替换exec 系列是如何换掉旧进程的很多初学者第一次接触 exec 时心里都藏着同一个疑问fork 不是已经创建了新进程吗为什么还要 exec难道 exec 是在新进程里再创建一个新进程答案是否定的。exec 不创建任何新进程它做的事只有一件把当前进程的地址空间彻底清空然后加载一个新的可执行文件进去从头开始跑。说白了这是同一个进程换了一道灵魂。4.1 exec 前后 PID 不变说明它没有创建新进程只要在 exec 前后各打印一次 getpid你就会发现 PID 完全一致。这个简单的实验可以粉碎exec 创建新进程的误解。正因为 exec 不产生新进程它也不能返回成功值——如果真的执行成功了当前进程的用户态代码已经被新程序替代旧代码里返回这个动作根本不可能发生。所以 exec 家族函数的返回值只有一种有意义的情况-1表示执行失败并且 errno 记录了失败原因。这就是 fork 和 exec 必须配合的底层原因。你要启动一个新程序但又想在启动前给子进程布置一些环境比如重定向标准输入输出、设置环境变量、修改信号掩码这些操作必须在子进程的地址空间里完成之后再 exec 去替换它。如果你只想单纯启动一个外部命令也可以直接用 posix_spawn 系列来封装这一套流程但大多数系统编程教材仍然建议你手写 fork exec因为这样你能清楚掌控每一步的细节。4.2 exec 六个函数的命名规律l、v、p、e 都是什么exec 家族在 Linux 下有六个成员execl、execlp、execle、execv、execvp、execvpe。名字看着唬人拆开就很简单。核心其实是 execve 这个系统调用其他五个都是 libc 提供的外层封装帮你把不同的传参形式转换为对 execve 的一次调用。命名规律很好记。llist表示参数以列表形式逐个传入最后必须以 NULL 结尾vvector表示参数放在一个以 NULL 结尾的字符串数组里。ppath表示如果第一个参数里没有斜杠就去 PATH 环境变量里的目录逐个查找可执行文件不带 p 的函数只接受明确的路径名。eenvironment表示可以额外指定环境变量数组而不是默认继承当前进程的 environ。函数参数格式使用 PATH 查找可自定义环境execl列表否否execlp列表是否execle列表否是execv数组否否execvp数组是否execvpe数组是是我在平时写代码时最常用的是 execvp原因是传一个 char* argv[] 数组就能启动命令并且支持 PATH 查找行为接近你在 shell 里直接敲命令的效果。需要精细控制环境变量时再用 execvpe 或 execle。用 execl 的时候最容易犯的错是漏写参数列表末尾的 NULL漏掉之后后果不可预期因为程序会在栈上继续往下读随机内容当参数。4.3 exec 之后哪些状态保留、哪些会被清掉这个问题在面试和实际排障里都很有价值。exec 替换的是进程地址空间但进程的很多身份信息并不住在地址空间里而是住在内核的进程控制块中所以会原样保留。保留项包括PID、父进程 PID、进程组 ID、会话 ID、真实/有效用户 ID、当前工作目录、根目录、umask、文件描述符如果没有设置 FD_CLOEXEC 标志、已经设置为 SIG_IGN 的信号处置、资源限制、定时器等等。这是 exec 的设计意图你要启动一个新程序但它的身份仍然是你的孩子它仍然继承你给它安排的目录和描述符。被清掉和重置的包括所有捕获信号处理函数会重置为默认行为所有 atexit/on_exit 注册的处理函数会失效stdio 缓冲区里的内容直接丢失映射到内存中的旧程序代码和数据全部作废。这解释了为什么 exec 成功后如果你在旧代码里留了没刷新的 printf 缓冲那些数据会消失得无影无踪。一个很实用的组合是 fork 子进程后先把不需要的 fd 加上 FD_CLOEXEC再 exec防止这些 fd 意外泄漏给新程序。如果你想彻底关掉某个 fd应该在 exec 之前 close但有些第三方库会在你不知情时打开 fd所以给文件描述符设置 close-on-exec 标志是更稳妥的全局策略。5. 实操串联用迷你 Shell 演示一次完整的进程管理前面讲了很多概念这一节把它们串成一个能跑的程序。一个最小可用的 Shell核心循环无非就是读取命令行、解析参数、fork 子进程、在子进程里做重定向和 exec、父进程 wait。你看这就是进程退出、等待、替换三件套最标准的应用现场。5.1 迷你 Shell 的主循环设计我用最精简的方式写了一个只能执行简单外部命令的 Shell 雏形。它不处理管道、不解析复杂语法但已经把退出、等待、替换这条链路完整走了一遍。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #include errno.h #define MAX_ARGS 64 #define MAX_LINE 256 void run_command(char *line) { char *argv[MAX_ARGS]; int argc 0; int background 0; char *saveptr NULL; char *token strtok_r(line, \t\n, saveptr); while (token argc MAX_ARGS - 1) { if (strcmp(token, ) 0) { background 1; break; } argv[argc] token; token strtok_r(NULL, \t\n, saveptr); } argv[argc] NULL; if (argc 0) { return; } fflush(NULL); pid_t pid fork(); if (pid 0) { perror(fork); return; } if (pid 0) { execvp(argv[0], argv); fprintf(stderr, mysh: %s: %s\n, argv[0], strerror(errno)); _exit(errno ENOENT ? 127 : 126); } if (!background) { int status; while (waitpid(pid, status, 0) 0 errno EINTR) { ; } } else { printf([%d] %s\n, pid, argv[0]); } } int main(void) { char line[MAX_LINE]; while (1) { printf(mysh ); fflush(stdout); if (!fgets(line, sizeof(line), stdin)) { break; } run_command(line); } return 0; }这个程序最重要的几个设计点每个都在呼应前面讲过的机制。第一fork 之前我调用了 fflush(NULL)把当前进程所有的 stdio 缓冲区都刷干净避免子进程继承到一份未刷新的缓冲副本等会儿要么重复输出要么丢失。第二子进程 exec 失败后用 _exit 而不是 exit避免把父进程的缓冲区和 atexit 处理函数再执行一遍。第三退出码用了 shell 惯例命令找不到返回 127找到了但无法执行返回 126。5.2 前台等待与后台放行的实现差异Shell 里真正有意思的是前后台任务的分流。前台命令执行时父进程用 waitpid 阻塞等待目标子进程退出期间用户什么都输入不了这是大多数人预期的交互行为。后台命令则完全不同父进程在 fork 之后立刻打印一个 PID然后继续回到读取命令行的主循环根本不去 wait。那后台子进程退出之后它的僵尸状态谁来清理在真正的 bash 里这个问题由 job control 机制负责后台任务退出时机比较复杂bash 会在合适的时机统一回收。在我这个迷你 Shell 里后台任务退出后短期内确实会变成僵尸只有等整个 Shell 进程退出时才会被系统统一收养。这也是为什么迷你 Shell 和真实 Shell 在实现细节上有明显差距。不过你仔细观察会发现后台任务的逻辑里有一点和前台共享父进程都不等待子进程 exec 的结果。exec 成功与否父进程是无法直接感知的它只能通过 wait 等待到的退出码或从管道读到的输出来间接判断。这也再次印证了 exec 之后父子之间的信息交互必须依赖别的渠道比如退出状态、管道、共享文件。6. 现场排障退出、等待、替换这三步最容易踩的 5 个坑理论讲得再多不如把实际踩过的坑摆出来。这一节我挑选了在项目里高频出现的问题每一个都有具体的现象、根因和修复方法。6.1 fork 前 printf 不加换行输出神秘消失现象父进程打印了一段提示信息然后 fork 子进程去 exec 外部程序结果那行提示怎么都看不到。原因在 stdio 缓冲。默认情况下标准输出如果指向终端是行缓冲模式如果被重定向到管道或文件就是全缓冲模式。printf 没有换行时数据留在用户态缓冲区里fork 会把这个缓冲区完整复制一份给子进程。如果子进程 exec 了一个新程序旧缓冲区在新程序加载时被丢弃那份复制品就彻底没了。如果子进程没有 exec而是做了一些计算后正常退出它又可能把复制品缓冲区再刷新一遍导致同一行内容输出两次。解决办法很明确在 fork 之前调用 fflush(NULL)或者保证出问题的 printf 都带换行符。这条经验在写守护进程、写带子进程的服务程序时几乎必用。6.2 子进程 exec 失败后使用 exit()触发连环副作用现象父进程和子进程的输出纠缠不清甚至 atexit 注册的清理函数被执行了两遍。这个坑往往出现在新手写的代码里子进程里调用 exec然后判断返回值等于 -1紧接着写了一句 exit(-1)。刚才说过exit 会刷新所有 stdio 缓冲区还会执行 atexit 函数。而 fork 之后子进程的缓冲区是父进程拷贝过来的exit 会把父进程还没来得及刷新的数据再多刷一次父进程的 atexit 函数清单也被子进程完整继承子进程退出时又跑一遍本来只该执行一次的清理逻辑被重复执行。正确收尾方式只该有一种exec 失败后立刻 _exit(126) 或 _exit(127)不做任何库级别的清理。如果你在子进程里处理的是管道exit 还可能在刷缓冲时触发不必要的 write 操作甚至阻塞在某个管道上让父进程永远等不到数据。6.3 waitpid 被信号打断返回 -1 且 errno 为 EINTR现象父进程明明只有一个子进程waitpid 却返回了 -1程序也没有崩溃但整个等待逻辑像被跳过了。这个问题的根源在信号处理。如果进程注册了某些信号处理函数比如周期性 alarm那么 waitpid 这类阻塞系统调用在收到信号时可能被中断直接返回 -1errno 被设置为 EINTR。对于初学者来说这个返回乍一看就像子进程没有正常退出很容易误判。正确的做法是把 waitpid 包在一个循环里遇到 EINTR 就继续等。上面迷你 Shell 的代码里我写的 while (waitpid(...) 0 errno EINTR) 就是这个意思。需要注意有些系统接口会被自动重启比如设置了 SA_RESTART 的信号处理但 wait 系列并不保证最稳妥的写法还是显式处理 EINTR。6.4 父子进程管道通信时没有关闭多余的描述符现象子进程把输出写入管道后父进程 read 却永远等不到 EOF数据明明已经写完了程序就是卡住不动。这几乎可以肯定是宏观的读端写端句柄泄漏问题。假设父进程创建 pipefork 后父子进程各拥有一份读端 fd 和写端 fd。父进程负责读但要写端 fd 如果不 close管道里就一直存在一个写端引用read 会认为还可能有人来写所以不会返回 0。同理子进程负责写应该把自己那份读端 fd 关掉。很多人在代码里记得 dup2 重定向却忘了在 exec 之前把多余的 fd 全部 close于是 exec 之后新程序也继承了这些 fd管道永远不会关闭死锁随之而来。排查这类问题最有效的手段是查看 /proc/ /fd看看进程实际持有哪些 fd一眼就能看出谁没关。6.5 把 Shell 的 $? 理解成 WEXITSTATUS 的直接来源现象父进程用 waitpid 获取子进程退出状态然后把自己通过 exit(128 sig) 传递给更上层的调用方上层再用 WEXITSTATUS 去解结果拿到一个完全对不上的值。这里容易混淆两套规则Shell 的 $? 显示 128信号编号是为了给人看的约定而内核 wait 状态字的信号记录方式完全不是这个形式。WEXITSTATUS 只对正常退出有效如果子进程是被信号杀死的WEXITSTATUS 解出来的数字没有业务含义你必须用 WIFSIGNALED 走另一条分支。另一个易错点是自己写程序时模仿 shell 的 128N 规则。按照这个规则退出你的父进程如果能区分被信号杀死和自己以 128N 退出看起来还行但只要父进程用了 WIFSIGNALED 判断它会把你的 128N 当作由编号为 N 的信号终止逻辑直接错乱。所以跨进程传递状态时能不用 128N 约定就不用让它只停留在 shell 显示层就好。我自己的感觉是进程的退出、等待与替换这三块每块单独学都不难难的是把它们放在同一条时间线上理解。一个进程从 fork 出生到 exec 换血到退出编码再到被父进程 wait 解码本质上是一套围绕状态传递的协议。你只要多写几个把子进程、重定向、管道组合起来的小程序再配合 strace 观察系统调用和 /proc 目录观察进程状态这些机制就会从知识变成肌肉记忆。