学完 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 以后,内核把退出信息交出去,再完成最后的资源回收。
大致过程可以理解为:
text
子进程 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 函数接口
c
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
返回值有三种常见情况:
- 成功:返回被回收子进程的 PID;
- 失败:返回
-1,同时设置errno; - 已经没有可等待的子进程:通常会因为
ECHILD失败。
status 是输出型参数。父进程把局部变量的地址传给内核,等待成功以后,内核往这个地址写入子进程的退出状态。
如果父进程暂时不关心退出信息,可以传入 NULL:
c
pid_t pid = wait(NULL);
传 NULL 只表示"不需要把状态复制给我 ",并不表示"不要回收子进程"。成功返回时,子进程仍然会被回收,只是父进程不读取具体退出码。
3.2 调用 wait 时,子进程可能处于什么状态
wait 的关键行为取决于子进程当前的状态:
| 子进程状态 | wait 的行为 |
|---|---|
| 子进程仍在运行 | 父进程可能阻塞,直到子进程发生变化 |
| 子进程已经退出,但还没被回收 | 立即返回,并完成回收 |
| 没有符合条件的子进程 | 立即出错,返回 -1 |
所以"wait 一定阻塞"也不准确。它只有在需要等待、并且当前没有已经退出的子进程时才会阻塞。如果父进程调用得比较晚,子进程早就变成僵尸,wait 会马上收走退出信息。
当有多个子进程时,wait 会等待其中任意一个。它一次只处理一个已经结束的子进程,不能指望一次调用把所有子进程全部收完。
3.3 用代码观察退出码和终止信号
测试代码通过一个 sleep(20) 拉开时间窗口,让我们可以选择让子进程正常退出,或者在另一个终端把它杀掉。
c
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
int main(void)
{
pid_t pid;
if ((pid = fork()) == -1) {
perror("fork");
exit(1);
}
if (pid == 0) {
sleep(20);
exit(10);
} else {
int status;
pid_t ret = wait(&status);
if (ret > 0 && (status & 0x7F) == 0) {
printf("child exit code:%d\n", (status >> 8) & 0xFF);
} else if (ret > 0) {
printf("sig code : %d\n", status & 0x7F);
}
}
return 0;
}
正常等待 20 秒时,子进程执行 exit(10),程序输出:
text
child exit code:10
如果父进程还在等待时,在另一个终端向子进程发送 SIGKILL,程序输出:
text
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 在此基础上增加了两个控制点:
c
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
它的三个参数分别控制三件事:
pid:等哪个或哪一类子进程;status:退出状态放在哪里;options:使用阻塞等待,还是采用WNOHANG非阻塞检查。
课堂里最常用的是下面两种 pid:
pid 取值 |
等待对象 |
|---|---|
-1 |
任意一个子进程,效果与 wait 接近 |
pid > 0 |
PID 正好等于该值的指定子进程 |
所以:
c
wait(&status);
和:
c
waitpid(-1, &status, 0);
在等待任意一个子进程这件事上基本等效。
如果指定 PID,父进程就不会随便拿其他子进程的结果:
c
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 推荐使用标准宏
直接做位运算适合用来理解结构,但业务代码更推荐使用标准宏:
c
if (WIFEXITED(status)) {
int code = WEXITSTATUS(status);
// 子进程正常退出,code 是退出码
} else if (WIFSIGNALED(status)) {
int signal_number = WTERMSIG(status);
// 子进程被 signal_number 号信号终止
}
它们对应的职责是:
WIFEXITED(status):判断子进程是否正常退出;WEXITSTATUS(status):在正常退出的前提下提取退出码;WIFSIGNALED(status):判断子进程是否被信号终止;WTERMSIG(status):在信号终止的前提下提取信号编号。
WEXITSTATUS 和 WTERMSIG 都有前置条件。比如子进程是被信号杀死的,再调用 WEXITSTATUS 就没有意义。更稳妥的判断顺序是先确认 waitpid 返回了目标子进程的 PID,再判断状态类型:
c
pid_t ret = waitpid(child_pid, &status, 0);
if (ret == child_pid && WIFEXITED(status)) {
printf("exit code: %d\n", WEXITSTATUS(status));
} else if (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:
text
257 = 0x101
257 & 0xFF = 0x01 = 1
所以阻塞等待示例中的子进程执行:
c
exit(257);
父进程通过 WEXITSTATUS(status) 得到的却是:
text
1
这不是 waitpid 把结果抄错了,而是进程退出码本身只有 8 位。想让子进程告诉父进程一个更大的数,不能依赖退出码,应该使用管道、文件、共享内存或专门的 IPC 机制。
6. 阻塞等待:把 CPU 交出去,等子进程结束
6.1 完整示例
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) {
printf("%s fork error\n", __FUNCTION__);
return 1;
} else if (pid == 0) {
printf("child is run, pid is : %d\n", getpid());
sleep(5);
exit(257);
} else {
int status = 0;
pid_t ret = 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");
return 1;
}
}
return 0;
}
运行结果是:
text
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,再读取状态更严谨:
c
if (ret == pid && WIFEXITED(status)) {
printf("child return code: %d\n", WEXITSTATUS(status));
}
6.2 阻塞期间内核在做什么
这里的"阻塞"不是父进程在 CPU 上一直循环检查,也不是整个系统停下来了。
父进程调用 waitpid 后,内核会做大致几件事:
- 在父进程的子进程集合里寻找符合条件的子进程;
- 如果找到已经终止的子进程,读取退出状态,回收对应记录;
- 如果子进程还在运行,就把父进程放到等待队列,并让出 CPU;
- 子进程退出时,内核发出子进程状态变化通知;
- 父进程被唤醒,重新检查子进程状态;
- 把
status复制到父进程的地址空间,并返回被回收子进程的 PID。
父进程阻塞时通常处于可被唤醒的睡眠状态,不再参与正常运行调度。其他进程仍然可以使用 CPU,所以"父进程阻塞"和"系统卡死"完全是两回事。
这也是阻塞等待适合的使用场景:父进程没有别的事情可做,只需要等子进程完成任务,把 CPU 交出去反而更高效。
7. 非阻塞等待:父进程一边检查,一边做自己的事
7.1 WNOHANG 改变了什么
把 options 改成 WNOHANG 后,waitpid 的行为变成:
- 如果目标子进程已经退出,立即回收并返回它的 PID;
- 如果目标子进程还在运行,立即返回
0,父进程继续做其他事; - 如果调用出错,返回
-1。
WNOHANG 的字面意思是"不挂起"。它不是让父进程换一种方式死等,而是让这次检查变成一次非阻塞尝试。
7.2 完整示例
课件里的非阻塞示例还放了一组临时任务,用函数指针数组模拟父进程在子进程运行期间可以处理的工作。
cpp
#include <iostream>
#include <vector>
#include <cstdlib>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
typedef void (*handler_t)();
std::vector<handler_t> handlers;
void fun_one()
{
std::cout << "这是一个临时任务1" << std::endl;
}
void fun_two()
{
std::cout << "这是一个临时任务2" << std::endl;
}
void Load()
{
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
void handler()
{
if (handlers.empty()) {
Load();
}
for (handler_t task : handlers) {
task();
}
}
int main()
{
pid_t pid = fork();
if (pid < 0) {
std::cout << "fork error" << std::endl;
return 1;
} else if (pid == 0) {
std::cout << "child is run, pid is : " << getpid() << std::endl;
sleep(5);
exit(1);
} else {
int status = 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;
return 1;
}
}
return 0;
}
这段程序没有固定的打印次数,因为父进程每轮循环能执行多少次,取决于调度器和机器负载。运行形态大致是下面这样,但重复次数并不固定:
text
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 负责让进程开始分叉,进程等待负责让这段父子关系有始有终。