【Linux 系统】进程终止、退出状态与进程等待
父进程创建子进程以后不只是让子进程完成任务就结束了。父进程还需要知道子进程是正常完成还是被信号终止如果正常结束结果是否成功最后还要把内核保留的子进程终止信息回收掉。这一过程连接了进程终止、退出状态、僵尸进程和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 位。因此退出状态最好限定在0255exit(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()#includestdlib.hvoidexit(intstatus);exit()是 C 库提供的正常终止接口。它不会返回并会进行用户态终止处理调用 exit(status) ↓ 按注册顺序的逆序执行 atexit() 等清理函数 ↓ 刷新并关闭 stdio 流 ↓ 删除 tmpfile() 创建的临时文件 ↓ 进入底层进程终止过程_exit()与_Exit()#includeunistd.hvoid_exit(intstatus);#includestdlib.hvoid_Exit(intstatus);二者都跳过atexit()处理和 stdio 流刷新直接进入进程终止阶段。不过“直接终止”不等于完全不做内核清理打开的文件描述符仍会关闭子进程会被重新托管父进程仍可收到SIGCHLD并取得终止状态。对比可以整理成行为returnfrommainexit()_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()函数原型#includesys/wait.hpid_twait(int*wstatus);pid_twaitpid(pid_tpid,int*wstatus,intoptions);wait(status)等价于waitpid(-1,status,0);它会等待任意一个直接子进程终止。传入NULL仍然会回收子进程只是父进程主动放弃读取终止状态。waitpid()的pid参数pid取值等待目标pid 0PID 等于该值的直接子进程pid -1任意一个直接子进程pid 0与调用者处于同一进程组的任意子进程pid -1进程组 ID 等于 waitpid()不能等待系统中的任意 PID只能等待满足条件的子进程。阻塞等待时的三种情况目标子进程已经终止 └─► 立即返回子进程 PID并完成回收 目标子进程存在但仍在运行 └─► options 为 0 时阻塞直到出现可等待状态 不存在满足条件且尚未回收的子进程 └─► 返回 -1errno 通常为 ECHILD阻塞的wait()或waitpid()还可能被信号中断并返回-1、设置errno EINTR。健壮代码通常需要判断错误原因遇到EINTR时重新等待而不是把它当作子进程失败。options参数选项作用0默认等待终止事件必要时阻塞WNOHANG当前没有符合条件的状态变化时立即返回 0WUNTRACED也报告被信号暂停的子进程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#includeerrno.h#includesignal.h#includestdio.h#includestdlib.h#includesys/types.h#includesys/wait.h#includeunistd.henumtermination_mode{NORMAL_TERMINATION,SIGNAL_TERMINATION};staticpid_tstart_child(enumtermination_modemode){pid_tpidfork();if(pid!0){returnpid;}if(modeNORMAL_TERMINATION){_exit(42);}raise(SIGTERM);_exit(125);}staticintwait_for_child(pid_tpid,int*status){pid_tresult;do{resultwaitpid(pid,status,0);}while(result-1errnoEINTR);returnresultpid?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_childstart_child(NORMAL_TERMINATION);if(normal_child-1){perror(fork);return1;}intnormal_status0;if(wait_for_child(normal_child,normal_status)-1){perror(waitpid);return1;}if(report_status(normal termination,normal_status)-1){return1;}pid_tsignal_childstart_child(SIGNAL_TERMINATION);if(signal_child-1){perror(fork);return1;}intsignal_status0;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, code42 signal termination: signaled, signal15第一行只有在WIFEXITED为真后才读取WEXITSTATUS。第二行只读取WTERMSIG没有把信号编号 15 当成子进程主动返回的退出码。信号的数字是本次 Linux 环境中的结果程序逻辑使用的是符号SIGTERM不应依赖手写的数字。7. 阻塞等待与非阻塞检查阻塞等待waitpid(pid, status, 0)在目标子进程仍运行时让调用线程睡眠不会持续占用 CPU 反复检查。父进程当前没有其他工作或者后续逻辑必须等子进程完成时阻塞等待最简单。WNOHANGwaitpid(pid, status, WNOHANG)有三类返回值返回值含义 0得到发生状态变化的子进程 PID并已完成相应等待操作0目标子进程存在但当前没有符合条件的状态变化-1调用失败例如没有可等待的子进程返回 0 只表示“这一次没有取得结果”不表示子进程已经被回收。父进程必须在以后再次等待。非阻塞检查后继续工作下面让子进程先阻塞在管道读取上因此父进程第一次非阻塞检查时子进程一定仍在运行。父进程处理一项工作后再通知子进程退出最后完成等待。#define_POSIX_C_SOURCE200809L#includeerrno.h#includestdio.h#includestdlib.h#includesys/types.h#includesys/wait.h#includeunistd.hstaticintreceive_token(intfd){chartoken0;ssize_tresult;do{resultread(fd,token,1);}while(result-1errnoEINTR);returnresult1tokenQ?0:-1;}staticintsend_token(intfd){constchartokenQ;ssize_tresult;do{resultwrite(fd,token,1);}while(result-1errnoEINTR);returnresult1?0:-1;}staticpid_twaitpid_retry(pid_tpid,int*status,intoptions){pid_tresult;do{resultwaitpid(pid,status,options);}while(result-1errnoEINTR);returnresult;}intmain(void){setvbuf(stdout,NULL,_IONBF,0);intgate[2];if(pipe(gate)-1){perror(pipe);return1;}pid_tpidfork();if(pid-1){perror(fork);return1;}if(pid0){close(gate[1]);if(receive_token(gate[0])-1){_exit(125);}close(gate[0]);_exit(7);}close(gate[0]);intstatus0;pid_tresultwaitpid_retry(pid,status,WNOHANG);if(result0){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]);resultwaitpid_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, code7第一次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只改变本次等待是否阻塞不会自动替父进程完成未来的回收工作。