20、进程控制(三):wait()、waitpid() 系统调用,僵尸进程与孤儿进程
好,咱们接着聊进程控制。前两节我们把 fork() 和 exec 族函数讲透了,你大概已经能熟练地「生」出子进程,再「换」成别的程序了。但有个问题我一直没提——子进程死了以后,谁来收尸?
嗯,这可不是开玩笑。在 Linux 的世界里,子进程退出后并不会立刻消失得干干净净。它会留下一具「尸体」,也就是所谓的僵尸进程。如果你不管它,它会一直占着进程表里的一个位置。我早年做嵌入式网关的时候,就因为这个 bug 导致系统跑了三天后 fork 不出新进程,排查了一整天才发现是僵尸堆满了。
所以这一节,咱们就专门讲「收尸」这件事。核心工具就是两个系统调用:wait() 和 waitpid()。顺便把僵尸进程和孤儿进程这两个经典概念一并拿下。
20.1 为什么需要 wait?—— 子进程的「临终遗言」
先想一个问题:父进程 fork 出子进程后,子进程独立运行。子进程退出时,内核会释放它占用的内存、打开的文件描述符等大部分资源。但有一小块信息会保留下来——进程描述符,里面存着退出状态、CPU 使用统计等。
这块信息谁来取走?父进程。父进程通过 wait() 或 waitpid() 来获取子进程的退出状态。取走之后,内核才彻底删除这个进程记录。
如果父进程一直不调用 wait,子进程就变成僵尸进程。僵尸进程不占 CPU,也不占内存(除了进程表项),但它占着一个 PID。PID 数量有限,僵尸多了,系统就没法创建新进程了。
20.2 wait() 系统调用 —— 最简单的收尸方式
wait() 的用法非常直白:父进程调用它,就会阻塞,直到任意一个子进程退出。然后它返回退出的子进程 PID,并通过指针传出退出状态。
原型长这样:
#include <sys/wait.h>
#include <unistd.h>
pid_t wait(int *status);
- 参数:
status是一个 int 指针,用来接收子进程的退出状态。如果你不关心状态,可以传 NULL。 - 返回值:成功返回子进程 PID,失败返回 -1。
看个最简单的例子:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("子进程运行中,PID=%d\n", getpid());
sleep(2);
exit(42); // 退出码 42
} else if (pid > 0) {
// 父进程
int status;
pid_t child_pid = wait(&status);
printf("子进程 %d 已退出\n", child_pid);
if (WIFEXITED(status)) {
printf("正常退出,退出码=%d\n", WEXITSTATUS(status));
}
}
return 0;
}
运行结果:
子进程运行中,PID=12345
(等待 2 秒)
子进程 12345 已退出
正常退出,退出码=42
这里有个细节:WIFEXITED(status) 宏判断子进程是否正常退出,WEXITSTATUS(status) 提取退出码。类似的宏还有 WIFSIGNALED 和 WTERMSIG,用来判断是否被信号杀死。
20.3 waitpid() 系统调用 —— 更精细的控制
wait() 有个明显的局限:它只能等待「任意一个」子进程。如果你有多个子进程,想等某个特定的子进程,或者不想阻塞,就得用 waitpid()。
原型:
pid_t waitpid(pid_t pid, int *status, int options);
参数 pid 的取值很有意思:
| pid 值 | 含义 |
|---|---|
| < -1 | 等待进程组 ID 等于 |pid| 的任意子进程 |
| -1 | 等待任意子进程(等价于 wait()) |
| 0 | 等待与父进程同组的任意子进程 |
| > 0 | 等待指定 PID 的子进程 |
options 参数最常用的是 WNOHANG,表示非阻塞。如果没有子进程退出,立即返回 0。
来看一个非阻塞轮询的例子:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
sleep(3);
exit(0);
}
// 父进程:非阻塞等待
int status;
pid_t ret;
while ((ret = waitpid(pid, &status, WNOHANG)) == 0) {
printf("子进程还没退出,我先干点别的...\n");
sleep(1);
}
if (ret == pid) {
printf("子进程终于退出了\n");
}
return 0;
}
输出:
子进程还没退出,我先干点别的...
子进程还没退出,我先干点别的...
子进程还没退出,我先干点别的...
子进程终于退出了
20.4 僵尸进程 —— 到底怎么产生的?
僵尸进程的产生条件很简单:子进程先退出,父进程没有(或来不及)调用 wait。
看这个反面教材:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("子进程退出\n");
exit(0);
} else {
// 父进程故意不调用 wait
printf("父进程睡大觉,不管子进程\n");
sleep(10); // 这 10 秒内子进程是僵尸
}
return 0;
}
运行后,在另一个终端执行 ps aux | grep Z,你会看到状态为 Z+ 的僵尸进程。
我曾经在一个项目中遇到过:一个后台服务进程 fork 出多个工作子进程,父进程只负责派发任务,忘了在子进程退出后调用 wait。结果系统跑了几天后,ps 一看,几十个僵尸进程。新任务 fork 不出来,服务就挂了。排查过程很痛苦,因为僵尸进程本身不报错,只是静悄悄地消耗 PID 资源。
20.5 孤儿进程 —— 被遗弃的孩子
与僵尸进程相反,孤儿进程是父进程先退出,子进程还在运行。这时候子进程变成孤儿,被 init 进程(PID=1)收养。init 进程会定期调用 wait 来清理这些孤儿,所以孤儿进程不会变成僵尸。
看个例子:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("子进程 PID=%d,父进程 PID=%d\n", getpid(), getppid());
sleep(5);
printf("5 秒后,父进程 PID=%d\n", getppid()); // 变成 1
} else {
// 父进程先退出
printf("父进程退出\n");
exit(0);
}
return 0;
}
输出:
父进程退出
子进程 PID=12346,父进程 PID=12345
(5 秒后)
5 秒后,父进程 PID=1
子进程的父进程 ID 从 12345 变成了 1,说明被 init 收养了。
20.6 知识体系总览
下面这张图把这一节的核心逻辑串起来了:
20.7 总结与避坑
好了,这一节的内容其实就三件事:
- wait() 阻塞等待任意子进程退出,适合简单场景。
- waitpid() 可以指定子进程,支持非阻塞,更灵活。
- 僵尸进程 是父进程没 wait 导致的,孤儿进程 是父进程先退出,被 init 收养。
最后分享一个我自己的血泪教训:
我曾经写一个多进程下载程序,父进程 fork 了 10 个子进程分别下载不同分片。我用了 wait() 来等待,但 wait() 只能等一个,我写了个循环调了 10 次 wait()。结果有个子进程因为网络超时一直没退出,整个程序就卡住了。后来改成 waitpid() 配合 WNOHANG,加上超时机制,才解决问题。
所以我的建议是:能用 waitpid() 就别用 wait(),哪怕只是传个 -1 和 WNOHANG。多花两行代码,少踩一个坑,值。
公众号:蓝海资料掘金营,微信deep3321