【Linux 底层原理】进程(十):进程等待详解---从僵尸进程到 wait、waitpid 与 status
学完fork以后,很容易产生一种错觉:父进程创建子进程,两边各跑各的,子进程执行完就自然消失了。真正写代码时才会发现,事情没有这么简单。子进程结束了,父进程还需要调用wait或waitpid;否则,一个已经“死了”的进程可能继续占着系统里的位置,甚至连kill -9都处理不了它。
这些现象背后连着几个问题:
- 子进程退出以后,为什么还要父进程“收尾”?
wait和waitpid到底在等什么?- 等待时,父进程真的还占着 CPU 吗?
- 子进程明明
exit(257),为什么拿到的退出码是1? - 父进程被阻塞和父进程一边轮询、一边做事,有什么区别?
这篇文章沿着课堂上的理解顺序,从僵尸进程的问题讲起,再看wait、waitpid、退出状态status,最后落到阻塞和非阻塞等待。
目录
- 子进程已经结束,父进程为什么还要等
- 僵尸进程:已经死了,为什么还占着位置
wait:先把最基本的等待接口用起来waitpid:把“等谁”和“怎么等”拆开status:退出码为什么不能直接当成整数读- 阻塞等待:把 CPU 交出去,等子进程结束
- 非阻塞等待:父进程一边检查,一边做自己的事
- 把整条知识链串起来
1. 子进程已经结束,父进程为什么还要等
父进程创建子进程,通常不是为了“生出来就不管”。它可能要依靠子进程完成一段任务,再根据执行结果决定下一步。
比如父进程启动一个子进程去处理数据,子进程完成后需要告诉父进程:
- 任务是正常做完的,还是被信号打断的;
- 如果是正常结束,退出码是多少;
- 父进程什么时候可以确定子进程的任务已经彻底结束。
光看子进程“不运行了”并不够。子进程可能正常退出,也可能被SIGKILL或其他信号终止,还可能发生段错误。父进程需要一种确定的方式,拿到这些退出信息,并且在拿到信息之后把子进程留下的内核记录清理掉。
这就是进程等待要解决的两个核心问题:
- 回收子进程仍然保留的资源;
- 获取子进程的退出信息。
注意,“子进程退出”和“父进程回收子进程”不是同一件事。退出发生在子进程一侧,表示它的任务已经结束;回收发生在父进程一侧,表示父进程承认这个结果,并让内核把剩余记录清掉。
2. 僵尸进程:已经死了,为什么还占着位置
2.1 为什么不能一退出就彻底删除
子进程调用exit后,用户态代码已经停止执行。它的用户地址空间、打开的文件等大部分资源会被释放,但它不能立刻从系统里彻底消失,因为内核还要给父进程留下一份“死亡通知”。
这份通知至少要回答几个问题:
- 子进程的 PID 是什么?
- 它是正常退出还是被信号杀死?
- 正常退出时的退出码是多少?
- 被信号终止时,是哪个信号?
如果子进程一退出就把所有记录删掉,父进程以后再调用wait时,就不知道刚才结束的是谁,也不知道它为什么结束。
所以内核会先保留一小部分进程记录,把子进程标记成僵尸状态,等待父进程来读取结果。父进程调用wait或waitpid以后,内核把退出信息交出去,再完成最后的资源回收。
大致过程可以理解为:
子进程 exit() | v 内核释放它的大部分资源,保留退出信息 | v 子进程进入 EXIT_ZOMBIE 状态 | v 父进程调用 wait() / waitpid() | v 内核返回子进程 PID 和退出状态 | v 释放僵尸进程剩余的内核记录在 Linux 中,这段过程的典型内核路径包括do_exit()、退出通知以及后续的release_task()。不同内核版本的函数细节可能变化,但职责可以概括为:子进程负责结束自己,父进程负责收尾。
2.2 “僵尸进程造成内存泄漏”要说得更准确
用“内存泄漏”来形容僵尸进程,作为第一层理解没有问题,但它不是特别精确。
僵尸进程的用户态内存通常已经释放,真正留下的是内核里的进程描述信息、PID 等有限资源。一个僵尸进程不会继续执行代码,也不会占着大量用户态内存,但它会占着进程表中的一个位置和对应的 PID。
如果父进程不断创建子进程,又一直不回收,僵尸就可能越积越多。数量足够大以后,PID 和内核进程资源会被耗尽,系统无法再创建新进程。这种后果和资源泄漏很像,所以可以把它理解成“内核资源被持续占住”,但不能简单说成一整块用户内存没有释放。
2.3 为什么kill -9对僵尸无效
SIGKILL能强制结束一个还在运行、尚未退出的普通进程,但它不能让一个僵尸“再死一次”。
僵尸已经完成了退出过程。它没有用户态执行流可以终止,剩下的只是等待父进程读取的退出记录。此时再向它发送信号,不会让它重新执行退出逻辑,也无法代替父进程完成回收。
这也是“谁也杀不死一个已经死去的进程”这句话真正的含义。
容易混淆的一点:如果子进程还在运行,
kill -9可以杀死它,但它随后仍可能变成僵尸,直到父进程回收。kill -9解决的是“让运行中的进程结束”,不是“替父进程善后”。
如果父进程先退出,僵尸通常会由新的父进程接管。在常见系统中,这个角色由init或类似的收养者承担,后面仍会有人负责回收。因此父进程退出确实能打破“僵尸一直留着”的局面,但这只是兜底机制,不应该拿来代替正常的进程等待。
3.wait:先把最基本的等待接口用起来
3.1 函数接口
#include<sys/types.h>#include<sys/wait.h>pid_twait(int*status);返回值有三种常见情况:
- 成功:返回被回收子进程的 PID;
- 失败:返回
-1,同时设置errno; - 已经没有可等待的子进程:通常会因为
ECHILD失败。
status是输出型参数。父进程把局部变量的地址传给内核,等待成功以后,内核往这个地址写入子进程的退出状态。
如果父进程暂时不关心退出信息,可以传入NULL:
pid_tpid=wait(NULL);传NULL只表示“不需要把状态复制给我”,并不表示“不要回收子进程”。成功返回时,子进程仍然会被回收,只是父进程不读取具体退出码。
3.2 调用wait时,子进程可能处于什么状态
wait的关键行为取决于子进程当前的状态:
| 子进程状态 | wait的行为 |
|---|---|
| 子进程仍在运行 | 父进程可能阻塞,直到子进程发生变化 |
| 子进程已经退出,但还没被回收 | 立即返回,并完成回收 |
| 没有符合条件的子进程 | 立即出错,返回-1 |
所以“wait一定阻塞”也不准确。它只有在需要等待、并且当前没有已经退出的子进程时才会阻塞。如果父进程调用得比较晚,子进程早就变成僵尸,wait会马上收走退出信息。
当有多个子进程时,wait会等待其中任意一个。它一次只处理一个已经结束的子进程,不能指望一次调用把所有子进程全部收完。
3.3 用代码观察退出码和终止信号
测试代码通过一个sleep(20)拉开时间窗口,让我们可以选择让子进程正常退出,或者在另一个终端把它杀掉。
#include<sys/types.h>#include<sys/wait.h>#include<stdio.h>#include<stdlib.h>#include<string.h>#include<errno.h>#include<unistd.h>intmain(void){pid_tpid;if((pid=fork())==-1){perror("fork");exit(1);}if(pid==0){sleep(20);exit(10);}else{intstatus;pid_tret=wait(&status);if(ret>0&&(status&0x7F)==0){printf("child exit code:%d\n",(status>>8)&0xFF);}elseif(ret>0){printf("sig code : %d\n",status&0x7F);}}return0;}正常等待 20 秒时,子进程执行exit(10),程序输出:
child exit code:10如果父进程还在等待时,在另一个终端向子进程发送SIGKILL,程序输出:
sig code : 9这里的9是SIGKILL的信号编号,不是子进程主动执行了exit(9)。两件事在结果上都表现为“进程结束了”,但内核记录的原因完全不同。
代码里的两个位运算值得先记住:
(status & 0x7F) == 0:低 7 位为 0,表示子进程正常退出;(status >> 8) & 0xFF:从高 8 位中取出正常退出码;status & 0x7F:取出终止子进程的信号编号。
后面会专门拆开status的位布局。实际项目里更推荐使用WIFEXITED、WEXITSTATUS等宏,不必每次都手写掩码。
4.waitpid:把“等谁”和“怎么等”拆开
wait的规则比较固定:等待任意一个子进程,默认阻塞。父进程如果只想等某个特定子进程,或者暂时不想被阻塞,wait就不够灵活了。
waitpid在此基础上增加了两个控制点:
#include<sys/types.h>#include<sys/wait.h>pid_twaitpid(pid_tpid,int*status,intoptions);它的三个参数分别控制三件事:
pid:等哪个或哪一类子进程;status:退出状态放在哪里;options:使用阻塞等待,还是采用WNOHANG非阻塞检查。
课堂里最常用的是下面两种pid:
pid取值 | 等待对象 |
|---|---|
-1 | 任意一个子进程,效果与wait接近 |
pid > 0 | PID 正好等于该值的指定子进程 |
所以:
wait(&status);和:
waitpid(-1,&status,0);在等待任意一个子进程这件事上基本等效。
如果指定 PID,父进程就不会随便拿其他子进程的结果:
waitpid(child_pid,&status,0);这里只等child_pid。即使别的子进程先结束,这次调用也不会因为那个子进程而返回。这个能力在多子进程程序中很重要,因为父进程往往需要知道“究竟是哪一个任务完成了”。
waitpid的返回值不能统一理解成“成功或失败”,要结合options判断:
| 返回值 | 含义 |
|---|---|
> 0 | 成功回收了一个子进程,返回值就是那个子进程的 PID |
0 | 使用了WNOHANG,当前没有已经退出、可以立即回收的子进程 |
-1 | 调用出错,具体原因看errno |
常见错误原因包括:
ECHILD:没有符合条件的子进程;EINTR:等待过程被信号打断。
严格来说,返回值0只应出现在WNOHANG这类非阻塞选项中。它表示“现在还没有结果”,不是“子进程永远没有结束”,所以调用方可以稍后再检查。
5.status:退出码为什么不能直接当成整数读
5.1 一个整数里装了两类信息
wait和waitpid都会交回一个int status,但它不是普通的“退出码变量”。
我们真正要表达的情况至少有两类:
- 子进程正常结束,并给出一个退出码;
- 子进程被某个信号终止。
如果只传一个退出码,就没法可靠地区分“子进程主动exit(9)”和“子进程被 9 号信号杀死”。为了用同一个整数保留完整信息,内核把不同含义编码到不同位段里。
只看 Linux 等待状态的低 16 位,可以大致画成:
| 位段 | 含义 |
|---|---|
| bit 0~6 | 终止子进程的信号编号;正常退出时为0 |
| bit 7 | 是否产生了核心转储 |
| bit 8~15 | 正常退出时的退出码 |
| bit 16~31 | 当前这种等待状态不使用 |
例如,子进程正常执行exit(10),退出状态的高 8 位会体现为10,终止信号字段为 0;子进程被SIGKILL杀死时,低 7 位是 9,而正常退出码字段没有意义。
5.2 推荐使用标准宏
直接做位运算适合用来理解结构,但业务代码更推荐使用标准宏:
if(WIFEXITED(status)){intcode=WEXITSTATUS(status);// 子进程正常退出,code 是退出码}elseif(WIFSIGNALED(status)){intsignal_number=WTERMSIG(status);// 子进程被 signal_number 号信号终止}它们对应的职责是:
WIFEXITED(status):判断子进程是否正常退出;WEXITSTATUS(status):在正常退出的前提下提取退出码;WIFSIGNALED(status):判断子进程是否被信号终止;WTERMSIG(status):在信号终止的前提下提取信号编号。
WEXITSTATUS和WTERMSIG都有前置条件。比如子进程是被信号杀死的,再调用WEXITSTATUS就没有意义。更稳妥的判断顺序是先确认waitpid返回了目标子进程的 PID,再判断状态类型:
pid_tret=waitpid(child_pid,&status,0);if(ret==child_pid&&WIFEXITED(status)){printf("exit code: %d\n",WEXITSTATUS(status));}elseif(ret==child_pid&&WIFSIGNALED(status)){printf("terminated by signal: %d\n",WTERMSIG(status));}5.3 为什么exit(257)最后变成了1
Unix/Linux 进程退出码的有效范围是 8 位,也就是0~255。子进程传给exit的整数只保留低 8 位,超出部分会被截断。
257的二进制低 8 位正好是1:
257 = 0x101 257 & 0xFF = 0x01 = 1所以阻塞等待示例中的子进程执行:
exit(257);父进程通过WEXITSTATUS(status)得到的却是:
1这不是waitpid把结果抄错了,而是进程退出码本身只有 8 位。想让子进程告诉父进程一个更大的数,不能依赖退出码,应该使用管道、文件、共享内存或专门的 IPC 机制。
6. 阻塞等待:把 CPU 交出去,等子进程结束
6.1 完整示例
#include<stdio.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>#include<unistd.h>intmain(void){pid_tpid=fork();if(pid<0){printf("%s fork error\n",__FUNCTION__);return1;}elseif(pid==0){printf("child is run, pid is : %d\n",getpid());sleep(5);exit(257);}else{intstatus=0;pid_tret=waitpid(-1,&status,0);printf("this is test for wait\n");if(WIFEXITED(status)&&ret==pid){printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));}else{printf("wait child failed, return.\n");return1;}}return0;}运行结果是:
child is run, pid is : 45110 this is test for wait wait child 5s success, child return code is :1.这里的 PID 每次运行都可能不同,最关键的是输出顺序。
子进程先打印自己的 PID,然后睡眠 5 秒。父进程进入waitpid(-1, &status, 0)后没有立刻打印this is test for wait,而是一直等到子进程退出,才继续执行。这说明options为0时,等待是阻塞式的。
原始代码里判断顺序可以再优化。虽然status已经被初始化为 0,但从语义上看,先确认ret == pid,再读取状态更严谨:
if(ret==pid&&WIFEXITED(status)){printf("child return code: %d\n",WEXITSTATUS(status));}6.2 阻塞期间内核在做什么
这里的“阻塞”不是父进程在 CPU 上一直循环检查,也不是整个系统停下来了。
父进程调用waitpid后,内核会做大致几件事:
- 在父进程的子进程集合里寻找符合条件的子进程;
- 如果找到已经终止的子进程,读取退出状态,回收对应记录;
- 如果子进程还在运行,就把父进程放到等待队列,并让出 CPU;
- 子进程退出时,内核发出子进程状态变化通知;
- 父进程被唤醒,重新检查子进程状态;
- 把
status复制到父进程的地址空间,并返回被回收子进程的 PID。
父进程阻塞时通常处于可被唤醒的睡眠状态,不再参与正常运行调度。其他进程仍然可以使用 CPU,所以“父进程阻塞”和“系统卡死”完全是两回事。
这也是阻塞等待适合的使用场景:父进程没有别的事情可做,只需要等子进程完成任务,把 CPU 交出去反而更高效。
7. 非阻塞等待:父进程一边检查,一边做自己的事
7.1WNOHANG改变了什么
把options改成WNOHANG后,waitpid的行为变成:
- 如果目标子进程已经退出,立即回收并返回它的 PID;
- 如果目标子进程还在运行,立即返回
0,父进程继续做其他事; - 如果调用出错,返回
-1。
WNOHANG的字面意思是“不挂起”。它不是让父进程换一种方式死等,而是让这次检查变成一次非阻塞尝试。
7.2 完整示例
课件里的非阻塞示例还放了一组临时任务,用函数指针数组模拟父进程在子进程运行期间可以处理的工作。
#include<iostream>#include<vector>#include<cstdlib>#include<sys/types.h>#include<sys/wait.h>#include<unistd.h>typedefvoid(*handler_t)();std::vector<handler_t>handlers;voidfun_one(){std::cout<<"这是一个临时任务1"<<std::endl;}voidfun_two(){std::cout<<"这是一个临时任务2"<<std::endl;}voidLoad(){handlers.push_back(fun_one);handlers.push_back(fun_two);}voidhandler(){if(handlers.empty()){Load();}for(handler_t task:handlers){task();}}intmain(){pid_t pid=fork();if(pid<0){std::cout<<"fork error"<<std::endl;return1;}elseif(pid==0){std::cout<<"child is run, pid is : "<<getpid()<<std::endl;sleep(5);exit(1);}else{intstatus=0;pid_t ret;do{ret=waitpid(-1,&status,WNOHANG);if(ret==0){std::cout<<"child is running"<<std::endl;}handler();}while(ret==0);if(ret==pid&&WIFEXITED(status)){std::cout<<"wait child 5s success, child return code is :"<<WEXITSTATUS(status)<<"."<<std::endl;}else{std::cout<<"wait child failed, return."<<std::endl;return1;}}return0;}这段程序没有固定的打印次数,因为父进程每轮循环能执行多少次,取决于调度器和机器负载。运行形态大致是下面这样,但重复次数并不固定:
child is run, pid is : ... child is running 这是一个临时任务1 这是一个临时任务2 child is running 这是一个临时任务1 这是一个临时任务2 ... wait child 5s success, child return code is :1.重点不是输出多少次,而是父进程在 5 秒等待窗口里没有停在waitpid里。它每检查一次:
waitpid(..., WNOHANG)发现子进程还没退出,返回0;- 父进程打印
child is running; - 父进程调用
handler()做临时任务; - 再进入下一轮检查。
子进程退出后,某一次waitpid返回子进程的 PID,循环条件不再成立,父进程读取退出状态并结束等待。
7.3 非阻塞不等于“没有成本”
这个示例使用了非常紧凑的轮询:只要子进程没结束,父进程就一轮接一轮检查。对于 5 秒的演示程序,很容易看明白知识点的作用;但放到真实程序里,如果什么都不做地高速轮询,会白白消耗 CPU。
实际工程中,非阻塞等待通常和事件循环、定时检查或其他任务配合使用。父进程确实有事情可做时,就在两次检查之间处理那些工作;没有事情可做时,也可以设置合适的等待节奏,而不是空转。
还有一个容易被忽略的点:waitpid也可能被信号打断并返回-1,此时errno == EINTR。如果程序要求非常稳健,可以把这种错误与真正的失败分开处理,决定是否重试。
8. 把整条知识链串起来
回头看,这几节其实是在逐步回答同一个问题:父进程怎样既知道子进程的结果,又把子进程完整地送走?
fork之后,父子进程各自执行。子进程结束任务,通过退出码或终止信号留下结果。它不能立刻把自己所有痕迹抹掉,因为父进程可能还没来取结果。于是它先进入僵尸状态,等待父进程处理。
父进程使用wait或waitpid读取结果,并让内核完成最后回收。wait适合“等任意一个子进程”;waitpid可以指定具体子进程,还可以通过WNOHANG改成非阻塞检查。
status不是简单的退出码。内核把“正常退出码”和“终止信号”放在同一个整数的不同位段里,因此要先通过WIFEXITED、WIFSIGNALED判断过程是怎么结束的,再读取对应信息。退出码只有 8 位,所以exit(257)最终拿到的是1。
阻塞和非阻塞的区别,则可以落到父进程是否愿意把 CPU 交出去:
| 对比项 | 阻塞等待 | 非阻塞等待 |
|---|---|---|
| 典型调用 | waitpid(pid, &status, 0) | waitpid(pid, &status, WNOHANG) |
| 子进程未退出时 | 当前执行流挂起,等待唤醒 | 立即返回0 |
| 子进程退出后 | 立即回收并返回 PID | 下一次检查时回收并返回 PID |
| 适合场景 | 后续工作必须依赖子进程结果 | 父进程还有别的任务要处理 |
最后需要分清四个容易混在一起的说法:
- 子进程退出:子进程自己的执行结束了;
- 子进程变成僵尸:内核保留了退出信息,等待父进程读取;
- 父进程等待成功:父进程拿到了结果,僵尸被回收;
- 进程被信号终止:这是子进程结束的一种方式,不等于正常退出码。
把这几层分清以后,wait、waitpid和status就不再是三组孤立的 API,而是一条完整的生命周期:子进程留下结果,父进程读取结果,内核完成清理。fork负责让进程开始分叉,进程等待负责让这段父子关系有始有终。