20、进程控制(三):wait()、waitpid() 系统调用,僵尸进程与孤儿进程

好,咱们接着聊进程控制。前两节我们把 fork() 和 exec 族函数讲透了,你大概已经能熟练地「生」出子进程,再「换」成别的程序了。但有个问题我一直没提——子进程死了以后,谁来收尸?

嗯,这可不是开玩笑。在 Linux 的世界里,子进程退出后并不会立刻消失得干干净净。它会留下一具「尸体」,也就是所谓的僵尸进程。如果你不管它,它会一直占着进程表里的一个位置。我早年做嵌入式网关的时候,就因为这个 bug 导致系统跑了三天后 fork 不出新进程,排查了一整天才发现是僵尸堆满了。

所以这一节,咱们就专门讲「收尸」这件事。核心工具就是两个系统调用:wait()waitpid()。顺便把僵尸进程和孤儿进程这两个经典概念一并拿下。

20.1 为什么需要 wait?—— 子进程的「临终遗言」

先想一个问题:父进程 fork 出子进程后,子进程独立运行。子进程退出时,内核会释放它占用的内存、打开的文件描述符等大部分资源。但有一小块信息会保留下来——进程描述符,里面存着退出状态、CPU 使用统计等。

这块信息谁来取走?父进程。父进程通过 wait()waitpid() 来获取子进程的退出状态。取走之后,内核才彻底删除这个进程记录。

如果父进程一直不调用 wait,子进程就变成僵尸进程。僵尸进程不占 CPU,也不占内存(除了进程表项),但它占着一个 PID。PID 数量有限,僵尸多了,系统就没法创建新进程了。

⚠️ 注意: 僵尸进程无法被 kill 杀死。因为它是「已死」的状态,kill -9 也没用。唯一的办法是杀掉它的父进程,让 init 进程接管并清理。

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) 提取退出码。类似的宏还有 WIFSIGNALEDWTERMSIG,用来判断是否被信号杀死。

💡 个人习惯: 我写代码时,即使不关心退出状态,也习惯传一个 status 变量进去,然后用 WIFEXITED 检查一下。万一子进程是被信号杀死的,你至少能知道原因,而不是一脸懵。

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;
}

输出:

子进程还没退出,我先干点别的...
子进程还没退出,我先干点别的...
子进程还没退出,我先干点别的...
子进程终于退出了
🔑 关键区别: wait() 是阻塞的,waitpid() 配合 WNOHANG 可以实现非阻塞。在嵌入式系统中,我经常用 WNOHANG 在主循环里轮询子进程状态,这样不会阻塞事件处理。

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 资源。

⚠️ 避坑指南: 如果你 fork 了子进程,一定要在某个地方调用 wait 或 waitpid。哪怕你不在乎退出状态,也要调用 wait(NULL) 来清理。否则僵尸进程会累积。

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 收养了。

💡 实际应用: 孤儿进程机制其实很有用。比如你写一个守护进程,可以 fork 一次然后让父进程退出,子进程继续运行。这样子进程就脱离了终端控制,变成后台服务。很多 Linux 服务就是这么启动的。

20.6 知识体系总览

下面这张图把这一节的核心逻辑串起来了:

进程控制(三):wait/waitpid 与进程状态 父进程 fork() 子进程 运行中 子进程退出 父进程是否调用 wait? wait() / waitpid() 获取退出状态,清理进程表 僵尸进程 占用 PID,无法被 kill 孤儿进程 父进程先退出 被 init 收养 核心原则:有 fork 必有 wait,否则僵尸丛生

20.7 总结与避坑

好了,这一节的内容其实就三件事:

  1. wait() 阻塞等待任意子进程退出,适合简单场景。
  2. waitpid() 可以指定子进程,支持非阻塞,更灵活。
  3. 僵尸进程 是父进程没 wait 导致的,孤儿进程 是父进程先退出,被 init 收养。

最后分享一个我自己的血泪教训:

我曾经写一个多进程下载程序,父进程 fork 了 10 个子进程分别下载不同分片。我用了 wait() 来等待,但 wait() 只能等一个,我写了个循环调了 10 次 wait()。结果有个子进程因为网络超时一直没退出,整个程序就卡住了。后来改成 waitpid() 配合 WNOHANG,加上超时机制,才解决问题。

所以我的建议是:能用 waitpid() 就别用 wait(),哪怕只是传个 -1 和 WNOHANG。多花两行代码,少踩一个坑,值。

📌 记住一句话: 你 fork 出来的每一个子进程,都值得你为它调用一次 waitpid()。这是对系统资源的尊重,也是对自己代码质量的负责。

公众号:蓝海资料掘金营,微信deep3321