☰
【Linux 底层原理】进程(十):进程等待详解---从僵尸进程到 wait、waitpid 与 status
2026/11/5 3:51:27 网站建设 项目流程

学完fork以后,很容易产生一种错觉:父进程创建子进程,两边各跑各的,子进程执行完就自然消失了。真正写代码时才会发现,事情没有这么简单。子进程结束了,父进程还需要调用wait或waitpid;否则,一个已经“死了”的进程可能继续占着系统里的位置,甚至连kill -9都处理不了它。

这些现象背后连着几个问题:

  • 子进程退出以后,为什么还要父进程“收尾”?
  • wait和waitpid到底在等什么?
  • 等待时,父进程真的还占着 CPU 吗?
  • 子进程明明exit(257),为什么拿到的退出码是1?
  • 父进程被阻塞和父进程一边轮询、一边做事,有什么区别?

这篇文章沿着课堂上的理解顺序,从僵尸进程的问题讲起,再看wait、waitpid、退出状态status,最后落到阻塞和非阻塞等待。

目录

  1. 子进程已经结束,父进程为什么还要等
  2. 僵尸进程:已经死了,为什么还占着位置
  3. wait:先把最基本的等待接口用起来
  4. waitpid:把“等谁”和“怎么等”拆开
  5. status:退出码为什么不能直接当成整数读
  6. 阻塞等待:把 CPU 交出去,等子进程结束
  7. 非阻塞等待:父进程一边检查,一边做自己的事
  8. 把整条知识链串起来

1. 子进程已经结束,父进程为什么还要等

父进程创建子进程,通常不是为了“生出来就不管”。它可能要依靠子进程完成一段任务,再根据执行结果决定下一步。

比如父进程启动一个子进程去处理数据,子进程完成后需要告诉父进程:

  • 任务是正常做完的,还是被信号打断的;
  • 如果是正常结束,退出码是多少;
  • 父进程什么时候可以确定子进程的任务已经彻底结束。

光看子进程“不运行了”并不够。子进程可能正常退出,也可能被SIGKILL或其他信号终止,还可能发生段错误。父进程需要一种确定的方式,拿到这些退出信息,并且在拿到信息之后把子进程留下的内核记录清理掉。

这就是进程等待要解决的两个核心问题:

  1. 回收子进程仍然保留的资源;
  2. 获取子进程的退出信息。

注意,“子进程退出”和“父进程回收子进程”不是同一件事。退出发生在子进程一侧,表示它的任务已经结束;回收发生在父进程一侧,表示父进程承认这个结果,并让内核把剩余记录清掉。

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 > 0PID 正好等于该值的指定子进程

所以:

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,但它不是普通的“退出码变量”。

我们真正要表达的情况至少有两类:

  1. 子进程正常结束,并给出一个退出码;
  2. 子进程被某个信号终止。

如果只传一个退出码,就没法可靠地区分“子进程主动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后,内核会做大致几件事:

  1. 在父进程的子进程集合里寻找符合条件的子进程;
  2. 如果找到已经终止的子进程,读取退出状态,回收对应记录;
  3. 如果子进程还在运行,就把父进程放到等待队列,并让出 CPU;
  4. 子进程退出时,内核发出子进程状态变化通知;
  5. 父进程被唤醒,重新检查子进程状态;
  6. 把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里。它每检查一次:

  1. waitpid(..., WNOHANG)发现子进程还没退出,返回0;
  2. 父进程打印child is running;
  3. 父进程调用handler()做临时任务;
  4. 再进入下一轮检查。

子进程退出后,某一次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负责让进程开始分叉,进程等待负责让这段父子关系有始有终。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询