【Linux 底层原理】进程(十):进程等待详解---从僵尸进程到 wait、waitpid 与 status

学完 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 以后,内核把退出信息交出去,再完成最后的资源回收。

大致过程可以理解为:

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

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

  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 推荐使用标准宏

直接做位运算适合用来理解结构,但业务代码更推荐使用标准宏:

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 后,内核会做大致几件事:

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

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

相关推荐
小lu飞1 小时前
2026小红书小工具怎么制作:纯前端 ZIP 打包、沙箱限制与笔记挂载流程
前端·笔记·ai·ai编程
西西弗Sisyphus2 小时前
C++ 超时互斥锁(Timed Mutex):std::timed_mutex 与 std::unique_lock
c++
今天要早睡_4 小时前
C++01_入门基础复习
开发语言·c++
无心水5 小时前
24、系统架构师论文写作专题 核心考点与学习笔记
笔记·学习·系统架构·架构师·安全架构·软考架构师·软考系统架构设计师
蒸蒸yyyyzwd5 小时前
邓明 Agent 系统面试-意图识别学习笔记
笔记·学习
Android系统攻城狮5 小时前
Linux FFmpeg深度解析之avcodec_default_get_encode_buffer调用流程与实战(二十八)
linux·运维·服务器·ffmpeg·车载音视频·ffmpeg音视频
程序员初一5 小时前
8080 打不开的四层定位:监听地址、ufw、DOCKER 链、安全组
linux·ssh
程序开发阿元5 小时前
swappiness 60 和 10 差在哪:一台 1C1G 的三轮对比
linux·ssh
无足鸟ICT6 小时前
【RHCA+】mpstat命令(监控CPU)
linux