父进程创建子进程以后,不只是让子进程完成任务就结束了。父进程还需要知道:子进程是正常完成,还是被信号终止;如果正常结束,结果是否成功;最后还要把内核保留的子进程终止信息回收掉。
这一过程连接了进程终止、退出状态、僵尸进程和wait()、waitpid()。如果只记住几个函数原型,很容易把退出码、errno、终止信号和waitpid()的status混成一个整数。
0. 子进程结束以后发生了什么
先把一条完整的生命周期串起来:
父进程 fork() 创建子进程 ↓ 子进程执行自己的任务 ↓ 正常退出,或者被信号终止 ↓ 内核释放它的大部分运行资源 ↓ 保留 PID、终止原因、资源使用统计等少量信息 ↓ 通知父进程,并等待父进程读取 ↓ 父进程调用 wait() / waitpid() ↓ 取得终止信息并释放最后的进程表项父进程最终关心的终止信息至少有两种情况:
正常终止 ──► 读取程序给出的退出状态 信号终止 ──► 读取导致终止的信号编号这两条信息不能同时当作“退出码”处理。程序主动调用exit(3)、_exit(2)或从main()返回,才有程序给出的退出状态;如果进程被SIGTERM、SIGSEGV等信号终止,父进程得到的是终止信号。
1. 进程退出的三种结果
从程序设计角度,可以把进程结束分成三类。
| 情况 | 终止方式 | 父进程主要读取什么 |
|---|---|---|
| 代码执行完,业务结果正确 | 正常终止 | 退出状态,通常为 0 |
| 代码执行完,但业务结果失败 | 正常终止 | 非零退出状态,由程序约定含义 |
| 程序发生异常或收到终止信号 | 信号终止 | 导致终止的信号编号 |
“正常终止”只说明进程通过正常接口结束,不代表业务一定成功。例如,程序发现输入格式错误后调用exit(2),这仍然属于正常终止,只是退出状态表示失败。
退出状态是一份程序接口约定
在 Unix 和 Shell 使用习惯中,0 通常表示成功,非 0 表示失败。但具体非零值代表什么,需要由程序自己定义并写进接口说明。
0 ──► 成功 1 ──► 可以表示一般失败,也可以由程序定义成其他原因 2 ──► 可以表示参数错误,也可以由程序定义成其他原因 ……不能因为 Linux 上EPERM的错误编号碰巧是 1,就断言所有程序退出码 1 都表示“Operation not permitted”。退出状态和系统错误编号是两套不同的约定。
C 标准提供了EXIT_SUCCESS和EXIT_FAILURE,适合只区分成功与失败的程序;需要表达多个失败原因时,可以定义自己的退出状态枚举或常量。
父进程能取得多少位
通过exit(status)、_exit(status)或main()的返回值交给父进程时,父进程可取得的是status的最低 8 位。因此退出状态最好限定在0~255:
exit(256) ──► 父进程看到 0 exit(-1) ──► 父进程通常看到 255这不是建议使用负数,而是说明为什么进程退出接口不能携带任意大小的整数结果。需要返回复杂结果时,应使用管道、文件、共享内存或套接字等通信方式,退出状态只负责给出简短的完成情况。
2. 退出状态、errno和信号不是一回事
这三个概念经常因为都用整数表示而被混淆。
| 名称 | 谁产生 | 作用范围 | 正确解释方式 |
|---|---|---|---|
| 进程退出状态 | 应用程序 | 整个进程的一次运行 | 程序文档或自定义枚举 |
errno | 失败的系统调用或库函数 | 当前执行流最近一次相关失败 | perror()、strerror(errno) |
| 终止信号 | 内核或其他进程 | 异常或外部终止原因 | WTERMSIG()、信号名称 |
strerror()解释的是错误编号
strerror(errnum)用来把EINVAL、ENOENT、EPERM这类错误编号转换成文字说明。它并不知道某个程序如何设计退出状态,也不能把任意退出码翻译成该程序的失败原因。
例如,一个程序完全可以自行规定:
退出状态 1:配置格式错误 退出状态 2:输入数据不完整 退出状态 3:计算结果超出范围这三个数字与strerror(1)、strerror(2)、strerror(3)的系统错误文字没有必然联系。
另外,errno只有在函数返回值已经表明失败时才有意义。一次成功调用不保证把旧的errno清零,所以不能无条件打印errno,再用它判断刚才的调用是否成功。
正确思路是:
先检查函数返回值 ↓ 失败 立即保存 errno ↓ 再用 perror() 或 strerror() 输出原因Shell 中的$?
Bash 使用特殊参数$?保存最近一条命令或管道的状态,可以用echo "$?"查看。它同样遵循 0 表示成功、非 0 表示失败的习惯。
Bash 还会把部分情况转换成自己的状态值:
| 情况 | Bash 中常见状态 |
|---|---|
| 命令成功 | 0 |
| 找到命令但不能执行 | 126 |
| 找不到命令 | 127 |
| 被编号为 N 的致命信号终止 | 128 + N |
这里的128 + N是 Bash 向脚本暴露的约定,不是 C 程序解析waitpid()原始status的方法。一个程序也可以主动exit(143),所以只看 Shell 中的数字 143,不能永远断定它一定收到了SIGTERM。
3.return、exit()与_exit()
三种正常终止方式最终都能把最低 8 位退出状态交给父进程,但经过的清理层次不同。
从main()返回
初始调用的main()执行return n,在终止效果上等价于调用exit(n)。这里必须限定为main():普通函数中的return只把控制权交还调用者,并不会终止整个进程。
exit()
#include<stdlib.h>voidexit(intstatus);exit()是 C 库提供的正常终止接口。它不会返回,并会进行用户态终止处理:
调用 exit(status) ↓ 按注册顺序的逆序执行 atexit() 等清理函数 ↓ 刷新并关闭 stdio 流 ↓ 删除 tmpfile() 创建的临时文件 ↓ 进入底层进程终止过程_exit()与_Exit()
#include<unistd.h>void_exit(intstatus);#include<stdlib.h>void_Exit(intstatus);二者都跳过atexit()处理和 stdio 流刷新,直接进入进程终止阶段。不过“直接终止”不等于完全不做内核清理:打开的文件描述符仍会关闭,子进程会被重新托管,父进程仍可收到SIGCHLD并取得终止状态。
对比可以整理成:
| 行为 | returnfrommain | exit() | _exit()/_Exit() |
|---|---|---|---|
| 终止整个进程 | 是 | 是 | 是 |
执行atexit()清理函数 | 是 | 是 | 否 |
| 刷新 stdio 缓冲区 | 是 | 是 | 否 |
| 关闭内核文件描述符 | 是 | 是 | 是 |
适合fork()后exec失败的子进程 | 一般不选 | 容易重复清理父进程继承状态 | 通常选择 |
fork()会复制用户态 stdio 缓冲状态。如果子进程在exec失败后调用exit(),可能再次刷新从父进程继承的缓冲数据,或运行不适合在子进程中执行的清理函数。因此这类子进程通常使用_exit()报告失败。
被信号终止、调用abort()等异常终止路径,也不能假设一定执行atexit()清理或完整刷新 stdio。重要数据需要在正常运行过程中及时写入,而不是全部寄希望于退出阶段。
4. 为什么父进程需要等待
子进程终止时,内核已经可以回收地址空间、打开的文件描述符等大部分资源,但仍要保留少量信息,供父进程取得:
- 子进程 PID;
- 正常退出状态或终止信号;
- 部分资源使用统计;
- 供父进程确认终止事件所需的进程表项。
如果父进程一直不读取,这个已经停止执行、但仍保留终止记录的子进程就是僵尸进程。
子进程正在运行 ↓ 终止 大部分资源已经释放 ↓ 保留最小终止记录,进入僵尸状态 ↓ 父进程 wait 父进程取得终止信息 ↓ 最后的进程表项被释放所以,僵尸进程不是仍在后台执行的普通进程,也不是完整地址空间一直没有释放。它占用的是内核进程表项和少量终止信息;数量过多仍可能耗尽 PID 或进程表资源,导致系统无法继续创建进程。
向僵尸进程发送SIGKILL没有意义,因为它已经终止,没有可继续执行并处理终止动作的运行实体。解决方法是让父进程进行等待,或者结束、修复不履行回收责任的父进程。
如果父进程先退出,它的子进程会被最近的 subreaper 或init一类系统进程接管,接管者负责回收已经终止的子进程。服务程序也可以显式设置SIGCHLD处理策略,但如果选择自动丢弃子进程状态,就不能再指望通过waitpid()取得那份退出信息。
5.wait()与waitpid()
函数原型
#include<sys/wait.h>pid_twait(int*wstatus);pid_twaitpid(pid_tpid,int*wstatus,intoptions);wait(&status)等价于:
waitpid(-1,&status,0);它会等待任意一个直接子进程终止。传入NULL仍然会回收子进程,只是父进程主动放弃读取终止状态。
waitpid()的pid参数
pid取值 | 等待目标 |
|---|---|
pid > 0 | PID 等于该值的直接子进程 |
pid == -1 | 任意一个直接子进程 |
pid == 0 | 与调用者处于同一进程组的任意子进程 |
pid < -1 | 进程组 ID 等于 ` |
waitpid()不能等待系统中的任意 PID,只能等待满足条件的子进程。
阻塞等待时的三种情况
目标子进程已经终止 └─► 立即返回子进程 PID,并完成回收 目标子进程存在但仍在运行 └─► options 为 0 时阻塞,直到出现可等待状态 不存在满足条件且尚未回收的子进程 └─► 返回 -1,errno 通常为 ECHILD阻塞的wait()或waitpid()还可能被信号中断并返回-1、设置errno = EINTR。健壮代码通常需要判断错误原因,遇到EINTR时重新等待,而不是把它当作子进程失败。
options参数
| 选项 | 作用 |
|---|---|
0 | 默认等待终止事件,必要时阻塞 |
WNOHANG | 当前没有符合条件的状态变化时立即返回 0 |
WUNTRACED | 也报告被信号暂停的子进程 |
WCONTINUED | 也报告暂停后被SIGCONT恢复的子进程 |
当前范围主要使用0和WNOHANG。后两个选项说明waitpid()等待的不一定只有“进程彻底退出”,还可以在明确要求时观察暂停和继续事件。
6. 不要手工拆status的位
wait()和waitpid()的status是编码后的终止状态,不是直接的退出码。虽然某些 Linux 环境中可以观察到底层位布局,但应用程序应使用<sys/wait.h>提供的宏解释。
拿到 status ↓ WIFEXITED(status) 为真? ├─ 是:WEXITSTATUS(status) 读取正常退出状态 └─ 否 ↓ WIFSIGNALED(status) 为真? ├─ 是:WTERMSIG(status) 读取终止信号 └─ 否 ↓ 结合调用选项检查 WIFSTOPPED / WIFCONTINUED常用宏如下:
| 宏 | 什么时候使用 |
|---|---|
WIFEXITED(status) | 判断是否通过return、exit()或_exit()正常终止 |
WEXITSTATUS(status) | 仅在WIFEXITED为真时读取退出状态 |
WIFSIGNALED(status) | 判断是否被信号终止 |
WTERMSIG(status) | 仅在WIFSIGNALED为真时读取终止信号 |
WCOREDUMP(status) | 判断是否产生 core dump;跨平台代码需要检查该宏是否存在 |
WIFSTOPPED(status) | 判断是否报告了暂停事件 |
WSTOPSIG(status) | 读取导致暂停的信号 |
WIFCONTINUED(status) | 判断是否报告了恢复执行事件 |
不能先无条件调用WEXITSTATUS(status),也不应该用(status >> 8) & 0xff和status & 0x7f代替这些宏。宏表达了接口语义,也隔离了操作系统和架构的编码差异。
正常退出与信号终止
下面依次创建两个子进程:第一个正常返回状态 42,第二个收到SIGTERM。父进程分别等待并按终止类型读取结果。
#define_POSIX_C_SOURCE200809L#include<errno.h>#include<signal.h>#include<stdio.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>#include<unistd.h>enumtermination_mode{NORMAL_TERMINATION,SIGNAL_TERMINATION};staticpid_tstart_child(enumtermination_modemode){pid_tpid=fork();if(pid!=0){returnpid;}if(mode==NORMAL_TERMINATION){_exit(42);}raise(SIGTERM);_exit(125);}staticintwait_for_child(pid_tpid,int*status){pid_tresult;do{result=waitpid(pid,status,0);}while(result==-1&&errno==EINTR);returnresult==pid?0:-1;}staticintreport_status(constchar*label,intstatus){if(WIFEXITED(status)){printf("%s: exited, code=%d\n",label,WEXITSTATUS(status));return0;}if(WIFSIGNALED(status)){printf("%s: signaled, signal=%d\n",label,WTERMSIG(status));return0;}printf("%s: unexpected state change\n",label);return-1;}intmain(void){setvbuf(stdout,NULL,_IONBF,0);pid_tnormal_child=start_child(NORMAL_TERMINATION);if(normal_child==-1){perror("fork");return1;}intnormal_status=0;if(wait_for_child(normal_child,&normal_status)==-1){perror("waitpid");return1;}if(report_status("normal termination",normal_status)==-1){return1;}pid_tsignal_child=start_child(SIGNAL_TERMINATION);if(signal_child==-1){perror("fork");return1;}intsignal_status=0;if(wait_for_child(signal_child,&signal_status)==-1){perror("waitpid");return1;}if(report_status("signal termination",signal_status)==-1){return1;}return0;}运行结果:
normal termination: exited, code=42 signal termination: signaled, signal=15第一行只有在WIFEXITED为真后才读取WEXITSTATUS。第二行只读取WTERMSIG,没有把信号编号 15 当成子进程主动返回的退出码。信号的数字是本次 Linux 环境中的结果,程序逻辑使用的是符号SIGTERM,不应依赖手写的数字。
7. 阻塞等待与非阻塞检查
阻塞等待
waitpid(pid, &status, 0)在目标子进程仍运行时让调用线程睡眠,不会持续占用 CPU 反复检查。父进程当前没有其他工作,或者后续逻辑必须等子进程完成时,阻塞等待最简单。
WNOHANG
waitpid(pid, &status, WNOHANG)有三类返回值:
| 返回值 | 含义 |
|---|---|
> 0 | 得到发生状态变化的子进程 PID,并已完成相应等待操作 |
0 | 目标子进程存在,但当前没有符合条件的状态变化 |
-1 | 调用失败,例如没有可等待的子进程 |
返回 0 只表示“这一次没有取得结果”,不表示子进程已经被回收。父进程必须在以后再次等待。
非阻塞检查后继续工作
下面让子进程先阻塞在管道读取上,因此父进程第一次非阻塞检查时,子进程一定仍在运行。父进程处理一项工作后再通知子进程退出,最后完成等待。
#define_POSIX_C_SOURCE200809L#include<errno.h>#include<stdio.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>#include<unistd.h>staticintreceive_token(intfd){chartoken=0;ssize_tresult;do{result=read(fd,&token,1);}while(result==-1&&errno==EINTR);returnresult==1&&token=='Q'?0:-1;}staticintsend_token(intfd){constchartoken='Q';ssize_tresult;do{result=write(fd,&token,1);}while(result==-1&&errno==EINTR);returnresult==1?0:-1;}staticpid_twaitpid_retry(pid_tpid,int*status,intoptions){pid_tresult;do{result=waitpid(pid,status,options);}while(result==-1&&errno==EINTR);returnresult;}intmain(void){setvbuf(stdout,NULL,_IONBF,0);intgate[2];if(pipe(gate)==-1){perror("pipe");return1;}pid_tpid=fork();if(pid==-1){perror("fork");return1;}if(pid==0){close(gate[1]);if(receive_token(gate[0])==-1){_exit(125);}close(gate[0]);_exit(7);}close(gate[0]);intstatus=0;pid_tresult=waitpid_retry(pid,&status,WNOHANG);if(result==0){puts("first check: child is still running");}else{fprintf(stderr,"unexpected first wait result\n");return1;}puts("parent: handled one task without blocking");if(send_token(gate[1])==-1){perror("write");return1;}close(gate[1]);result=waitpid_retry(pid,&status,0);if(result!=pid){perror("waitpid");return1;}if(!WIFEXITED(status)){fprintf(stderr,"child did not exit normally\n");return1;}printf("final wait: child exited, code=%d\n",WEXITSTATUS(status));return0;}运行结果:
first check: child is still running parent: handled one task without blocking final wait: child exited, code=7第一次waitpid()返回 0,父进程没有停在等待中;但这次调用也没有回收子进程。最后仍然要再次等待,取得状态 7 并释放终止记录。
实际程序不应该在WNOHANG返回 0 后无间隔地高速空转,否则“非阻塞”会变成持续消耗 CPU 的忙轮询。父进程可以处理真正的工作、等待其他事件、采用合理的定时策略,或者结合SIGCHLD的事件通知机制。
8. 多个子进程应该怎样回收
父进程可以按已保存的 PID 逐个等待,也可以使用waitpid(-1, ...)回收任意一个已发生状态变化的子进程。
已知每个子进程 PID └─► 按 PID 调用 waitpid(),结果容易与任务对应 只关心谁先结束 └─► waitpid(-1, ...),谁先可等待就先回收谁如果使用SIGCHLD通知,不能假设“一次信号只对应一个子进程”。普通信号可能合并,多个子进程也可能在父进程处理前连续退出,所以常见做法是在合适的位置反复调用waitpid(-1, &status, WNOHANG),直到返回 0 或确认没有更多可回收子进程。
还要注意:
- 子进程可以在父进程调用
waitpid()之前就结束,终止信息会暂时保留,之后等待仍能立即取得; - 一次成功等待只回收一个满足条件的子进程状态;
wait(NULL)虽然不读取状态,也必须检查返回值;ECHILD表示当前没有符合条件且尚未回收的子进程;EINTR表示等待被信号中断,通常需要根据程序策略重试。
9. 常见误区
9.1strerror(退出码)能得到程序失败原因吗
不能。strerror()解释系统错误编号。退出码的含义由程序自己定义,除非该程序明确规定退出码直接复用某套错误编号。
9.2status == 256就表示退出码 1 吗
不要依赖这个原始数值。应先检查WIFEXITED(status),再使用WEXITSTATUS(status)得到 1。原始位布局不是应用代码应该手工解析的接口。
9.3 被信号终止后,退出码是不是信号编号
不是。父进程应走WIFSIGNALED、WTERMSIG分支。Bash 可能把信号 N 转换成128 + N展示给脚本,但这是 Shell 层的状态约定。
9.4 僵尸进程为什么不能再被kill -9杀死
因为它已经结束执行,只剩等待父进程读取的终止记录。需要的是回收,不是再次终止。
9.5 调用一次waitpid(..., WNOHANG)就完成回收了吗
只有返回值大于 0、确实取得了子进程状态时才完成相应等待。返回 0 时必须以后再次调用。
9.6exit()与_exit()只差一个输出缓冲区吗
不止。exit()还会执行atexit()等用户态终止处理;_exit()和_Exit()跳过这些处理。但它们最终都会触发内核层面的进程资源清理和父进程通知。
9.7 普通函数中的return 0会结束进程吗
不会。只有初始调用的main()返回才进入正常进程终止流程。普通函数的return只是返回调用点。
9.8 父进程等待时一定要比子进程晚退出吗
父进程必须在自己仍存在时完成需要的等待,但子进程可以先结束。子进程先结束后会留下可等待状态,父进程稍后调用waitpid()会立即取得结果。
10. 小结
进程终止和进程等待是一次完整的父子进程协作:
子进程正常结束 └─► 退出状态 ──► WIFEXITED + WEXITSTATUS 子进程被信号终止 └─► 终止信号 ──► WIFSIGNALED + WTERMSIG 父进程暂时不等待 └─► 子进程保留最小终止记录,成为僵尸 父进程执行等待 └─► 取得信息并释放最后的进程表项最需要分清的边界是:退出状态由应用程序定义,errno描述失败调用,信号说明异常或外部终止;status是编码结果,必须使用标准宏解释;WNOHANG只改变本次等待是否阻塞,不会自动替父进程完成未来的回收工作。