温馨提示:
本文为《深入理解Linux进程》系列文章的第二章,如果你还不清楚fork() 是如何克隆进程空间和父子进程如何通过返回值区分身份,建议先阅读前传(深入解析Linux进程创建:从fork()到实战),否则后续关于“回收孤儿”和“waitpid 控制”的内容可能会难以消化。

一、进程的五种基本状态

  1. 运行态(Running):正占用cpu运行的进程
  2. 就绪态(Ready):进程已经具备除cpu外的所有运行条件,等待操作系统调度分配cpu时间
  3. 阻塞态(Blocked/Waiting/Sleeping):进程正在等待某个事件发生(I/O完成,子进程结束,信号,锁等),在此期间即使有空闲cpu也不会允许
  4. 创建态(New):进程刚被fork()创建正在进行初始化(如分配 PCB、加载程序、申请内存等),尚未被放入就绪队列(这个状态持续时间很短常被忽略)
  5. 终止态(Terminated/Exit/Zombie):进程已经执行完毕(exit或return),释放了大部分资源,但进程控制块PCB和退出状态仍保留,等待父进程通过 wait() 或 waitpid() 回收

进程状态

强调:阻塞态是进程因资源未就绪而主动发起的让出行为(如 I/O、sleep),将 CPU 支配权交还给调度器。而就绪态则是进程万事俱备后,被动等待调度器分配时间片。(当然某些进程确实存在“赖皮”行为,即忙等待(Busy Waiting),在申请不到资源时拒绝进入阻塞态,而是持续占用 CPU 进行空转轮询。这种机制虽能减少切换状态的开销,但若使用不当(如在单核环境下),会严重浪费系统资源,甚至因违反“占有且等待”原则而成为引发系统死锁或死循环的诱因。)

二、进程状态的实时观察

STAT 值含义说明

状态 名称 描述
R Running 或 Runnable(运行态 / 就绪态) 进程正在 CPU 上运行,或者在就绪队列中等待 CPU(最活跃的状态)
S Sleeping(可中断睡眠,即阻塞态) 进程正在等待某个事件(如 键盘的I/O、sleep、wait 子进程等),可以被信号打断
D Disk sleep(不可中断睡眠) 进程正在等待磁盘 I/O 完成,通常是直接硬件交互,不能被普通信号打断(短暂)
T Stopped(停止态) 进程正在等待磁盘 I/O 完成,通常是直接硬件交互,不能被普通信号打断(短暂)
Z Zombie(僵尸态) 进程已终止,但父进程尚未回收其退出状态(僵尸进程)

状态实例
在终端运行ps aux 以此来查看系统当前的进程
状态观察
   判断进程是否“正常运行”主要看第一个主状态字母(R/S/Z 等)。剩下的是后缀符号,可以简单做一个了解

符号 名称 含义及应用场景 典型例子
+ 前台进程组 该进程正运行在当前终端的前台,可以直接接收键盘信号(如 Ctrl+C)。 执行 ls 或 top等
l(小写L) 多线程 进程内部启动了多个线程(使用 pthread 库)。 浏览器、数据库、Python 的多线程应用
s (小写S) 会话领导者 该进程是整个会话的首进程。通常负责管理一组进程组。 bash 终端、sshd 服务
< 高优先级 该进程的 nice 值为负数(数值越小优先级越高),CPU 会优先照顾它。 systemd-journald(系统日志处理)
N 低优先级 该进程的 nice 值为正数(数值越大优先级越低),也就是“老好人”模式。 自动更新程序、后台备份任务
L (大写L) 内存锁定 进程的部分页面被锁定在物理内存中,禁止被系统交换(Swap)到磁盘。 实时操作系统任务、加密软件(防密钥写盘)

三、进程状态与控制讲解

3.1 异常进程

  在进程的生命周期中,大多数进程都会正常诞生、运行、终止并被回收。但有时由于父子关系的处理不当,会出现两种特殊的“异常进程”:孤儿进程和僵尸进程。 它们并不是独立的进程类型,而是正常进程在特定情况下的异常表现。

3.1.1 进程的“社会结构”-- 理解异常进程的关键

在讲解孤儿与僵尸之前,我们先用一个“社会结构”的比喻来理解操作系统对进程关系的严格规则。
系统中有一个特殊的“官方机构”——init/systemd 初始化进程(PID 通常为 1),它是所有进程的终极“福利院”和兜底管理者。

进程之间是严格的父子关系:

  • 父进程对自己的直接子进程拥有绝对管治权(包括读取退出状态、回收资源的权利)。
  • 但爷爷对孙子没有直接管治权——正如古罗马谚语所说:“我仆从的仆从,不是我的仆从”。

现实中的监护权类比:

  • 孩子死亡 → 必须由父母处理后事,国家不会越权插手(除非父母失踪或死亡)。
  • 父母亡故 → 孩子通常先由近亲(如祖父母)监护,国家福利机构只在无亲属时介入。
    而操作系统更像一个“冷酷的古代宗法社会”:
  • 只有直系父母(直接父进程)才有第一监护权。
  • 一旦直接父进程死亡,后代(孩子、孙子、重孙……整棵子树)直接被判为孤儿,全部过继给官方福利院(PID=1)。
  • 上层进程(如爷爷)彻底失去管辖权,无权自动收养或干预。

这种设计保证了规则简单、责任明确,同时由 init/systemd (初始化进程) 提供终极兜底。

3.1.2 孤儿进程(Orphan Process)

定义: 父进程提前终止,而其子进程(包括孙子、重孙等后代)仍在运行,这些后代进程就成为孤儿进程。

发生过程:

  • 某个中间父进程终止。
  • 系统立即将它名下的整棵子树直接再父化(reparent)到 PID=1 的 init/systemd。
  • 这些进程的 ppid 变为 1,继续正常运行。
  • 当它们最终结束时,由 init/systemd 自动回收。

特征与危害:

  • ps 命令中状态正常(R/S 等),只是 PPID=1。
  • 几乎无害,系统自动善后。

代码示例(让父比子提前结束)

else if (pid == 0)
    {
        // 子进程:活得超级长,不断打印自己的 ppid
        printf("【子进程】PID = %d,初始父PID = %d,我要活 60 秒\n", getpid(), getppid());
        for (int i = 0; i < 60; i++)
        {
            printf("【子进程】第 %d 秒,当前父PID = %d\n", i + 1, getppid());
            sleep(1);
        }
        printf("【子进程】结束\n");
    }
    else
    {
        // 父进程:快速退出,不回收
        printf("【父进程】PID = %d,创建子进程 %d,父进程将几秒后退出\n", getpid(), pid);
        sleep(2); // 确保子进程先打印初始 ppid
        // 不 wait,直接结束
    }

运行结果

vege@groge:/mnt/c/Users/86177/codeFile/bee/OS/output$ ./"异常进程"
【父进程】PID = 9084,创建子进程 9085,父进程将几秒后退出
【子进程】PID = 9085,初始父PID = 9084,我要活 60 秒
【子进程】第 1 秒,当前父PID = 9084
【子进程】第 2 秒,当前父PID = 9084
【子进程】第 3 秒,当前父PID = 9084
vege@groge:/mnt/c/Users/86177/codeFile/bee/OS/output$ 【子进程】第 4 秒,当前父PID = 1
【子进程】第 5 秒,当前父PID = 1
【子进程】第 6 秒,当前父PID = 1
 ...
【子进程】第 60 秒,当前父PID = 1
【子进程】结束

父进程提前结束子进程被福利院回收

小结: 孤儿进程就像“父母跑路的孩子”,被国家福利院收养,继续正常生活,最终自然离场。

3.1.3僵尸进程(Zombie Process)

定义: 子进程已经终止,但其直接父进程尚未调用 wait() 或 waitpid() 回收退出状态,该子进程就成为僵尸进程。

发生过程:

  • 子进程先结束 → 进入终止态,保留 PCB 和退出信息,等待父进程回收。
  • 如果直接父进程还活着且不回收 → 子进程成为僵尸,挂在父进程名下。
  • ps 命令中显示 Z 状态,常伴随 。

特征与危害:

  • 不占用 CPU 和内存,但占用进程表槽位(PCB)。
  • 大量僵尸进程会耗尽系统进程资源,导致无法创建新进程(严重故障)。
    为什么 init/systemd 不直接清理? 因为管治权仍在原父进程手中,init/systemd 无权越俎代庖(否则原父进程就拿不到退出信息了)。

解决办法:

  • 父进程主动调用 wait/waitpid 回收(推荐)
  • 杀死父进程 → 系统将僵尸再父化到 init → init 立即清理

代码示例
创建无限运行的父子进程并且父进程没有进行资源回收的功能,通过手动终止子进程kill [pid]来看看会发生什么吧

if (pid < 0)
    {
        printf("fork 失败!\n");
        return 1;
    }
    else if (pid == 0)
    {

        printf("【子进程】PID = %d\n", getpid());
        while (1)
        {
            // 子进程一直运行
        }
    }
    else
    {
        printf("【父进程】PID = %d\n", getpid());
        while (1)
        {
            // 父进程一直运行
        }
    }

运行得:
【父进程】PID = 17005
【子进程】PID = 17006

通过命令 ps -p [pid] -o pid,ppid,state,cmd查看进程状态,父子进程状态:
父进程子进程
State为R表父子进程都处于运行态/就绪态,用kill [pid]我们来手动终止子进程看看会发生什么

在这里插入图片描述
由于父进程仍在运行并且没有自己回收子进程资源,再次查看子进程状态可见子进程变为僵尸态

在这里插入图片描述
从上文可知,当父进程没有回收子进程资源可以通过杀死父进程让父进程结束来令子进程挂到init/system下回收

在这里插入图片描述
杀死父进程,再次查看子进程状态, 没有输出,说明它已经被系统回收掉了

小结: 僵尸进程就像“孩子已死,父母不处理后事”。尸体一直占着位置,只有父母负责下葬,或父母自己死了,国家才会强制清理。

3.2 阻塞与唤醒

在父子进程协作中,父进程常常需要“等一等”子进程结束。这既是为了保持程序逻辑顺序(比如 shell 执行命令),也是为了回收子进程资源,避免产生僵尸进程。
Linux 提供了 wait() 系列函数来实现这个需求。下面从简单到复杂逐步介绍

3.2.1 wait(NULL)最常见的写法 :
pid_t wait(int *status);

关键参数:
status = NULL: 不关心子进程的退出详情
status = Int *类型的指针: 指针可以获得子进程如何结束(正常退出值,被哪个信号杀死等)
作用: 阻塞当前进程,直到任意一个子进程结束,然后回收它的资源。
返回值: 结束的子进程 PID(用于多子进程时判断是哪个)。
使用场景:子进程只有一个,或者不在乎结束顺序时。

3.2.2 waitpid(更灵活)
pid_t waitpid(pid_t pid, int *status, int options);

有的时候我们不能只凭任意一个子进程是否结束就让父进程阻塞或唤醒,就像爸爸在做番茄炒西红柿等着老大买西红柿回来,结果另一个进程老二刚刚买了马铃薯回来,它们两个大眼瞪小眼爸爸就做出来了一个马铃薯炒土豆,如果父进程有多个子进程每个负责不同的任务,用wait可能会错拿其它子进程的结果造成逻辑混乱。另外,爸爸在等老大买西红柿回来时它还可以先准备其它菜的底盘或调料,不需要完全停下等待浪费时间

关键参数:

  • pid = -1:行为和 wait(NULL) 完全一样(最常用)
  • pid > 0:只等待指定 PID 的子进程
  • options = 0:阻塞等待(默认)
  • options = WNOHANG:非阻塞,如果子进程还没结束,立刻返回 0,子进程结束则返回子进程pid(常用于轮询,爸爸每隔几分钟调用一次 waitpid看一眼门口,没回来就继续切姜末,而不是一直盯着门口看)

返回值:

  • 0 成功回收了一个子进程,返回值就是那个被回收的子进程的 PID
  • 0 options 为 WNOHANG(非阻塞)且目前需等待的子进程未结束
  • -1 出错或没有子进程可等了(所有子进程都已经回收完毕,或根本没子进程)

使用场景:

  • 多子进程并行,想指定等待某个
  • 父进程有其他事要做,不想完全卡在等待上

示例

pid_t tomato_child = fork();  // 老大:买西红柿(sleep 10 模拟)
if (tomato_child == 0) { sleep(10); exit(0); }
pid_t potato_child = fork();  // 老二:买马铃薯(sleep 3 模拟)
if (potato_child == 0) { sleep(3); exit(0); }
// 爸爸开始非阻塞轮询查看老大是否回来
for (int i = 0; i < 15; i++) {
    if (waitpid(tomato_child, NULL, WNOHANG) == tomato_child) {
        printf("西红柿回来了!开始做番茄炒西红柿\n");
        break;
    }
    printf("第 %d 秒:老大还没回来,我先准备调料/洗锅...\n", i);
    sleep(1);
}
// 老二早就回来了,但爸爸没被打扰
waitpid(potato_child, NULL, 0);  // 顺手回收老二

waitpid :不好队友!我有双重人格

在前面进程的五种基本状态那可以知道,存在“流氓”进程会进行非阻塞操作来等待资源造成忙等。waitpid 的正常人格是其参数 options = 0 的时候,执行到此,进程会主动切换到阻塞态让出 CPU;但是当 options = WNOHANG 且程序员没有合理使用(如 while(waitpid(…) == 0);)时,进程会始终保持运行态,每秒问八百遍“好了没?”,导致 CPU 空转。因此,正常的非阻塞轮询需要在循环中加入 sleep(),主动进入阻塞态以释放 CPU 资源。

3.2.3 waitid查看进程详细退出信息(了解即可):
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

前面两个wait(int *status)和waitpid(pid_t pid, int *status, int options)它们都可以接收status来获取进程退出的信息,但是如果想或得更加详细的退出信息就用waitid

常用参数:

  • idtype = P_PID:等待指定 PID 的进程
  • idtype = P_ALL:等待任意子进程
  • id = 具体PID 或 0(任意)
  • options:常用 WEXITED(等待退出)、WNOHANG(非阻塞)等,可组合

使用场景:

需要详细诊断子进程死亡原因的系统程序(如调试器、监控工具)

3.3 进程终止的正确方式

进程的终止不是简单地“跑完代码就结束”,而是需要父子进程配合完成。如果处理不当,会产生僵尸进程、资源泄漏等问题。下面介绍最实用、最正确的终止方式。

3.3.1 进程的终止方式:exit() / _exit() / return
方式 说明 何时推荐使用 是否刷新缓冲区
return n 在 main 函数中返回 n,等价于 exit(n) 最常用、最清晰,子进程正常结束时首选 yes
exit(n) 正常终止进程,刷新 stdio 缓冲区,调用 atexit 清理函数 任何位置主动结束进程 yes
_exit(n) 立即终止进程,不刷新缓冲区,不调用 atexit fork 后的子进程(防止重复输出缓冲区) no

注意:缓冲区刷新是指将用户空间stdio (standard input output标准输入输出)缓冲区的内容真正写到终端/文件。

由于 fork() 会把父进程的内存映像完全复制一份给子进程,因此父进程当时还没来得及刷新的 stdio 缓冲区,也会被完整地复制到子进程中。

现代实践口诀:
子进程 → 能用 return 就用 return
父进程 → 能用 return 就用 return
只有「明确/不清楚有未加\n的 printf 且不想看到重复输出」时,才用 _exit()

3.3.2 代码示例:多子进程场景,用循环 waitpid() 回收所有子进程
int main()
{
    // 创建 3 个子进程
    for (int i = 0; i < 3; i++)
    {
        pid_t pid = fork();
        if (pid < 0)
        {
            perror("fork");
            return 1;
        }
        if (pid == 0)
        {
            // 子进程
            printf("子进程 %d (PID=%d) 开始工作\n", i, getpid());
            sleep(i + 1); // 模拟不同工作时间
            printf("子进程 %d 结束\n", i);
            return i + 10; // 不同退出码
        }
    }

    // 父进程:回收所有子进程
    printf("父进程开始回收所有子进程...\n");
    int status;
    pid_t child_pid;
    while ((child_pid = waitpid(-1, &status, 0)) > 0)
    {
        if (WIFEXITED(status))
        {
            printf("回收子进程 %d,正常退出,退出码 = %d\n",
                   child_pid, WEXITSTATUS(status));
        }
        else if (WIFSIGNALED(status))
        {
            printf("子进程 %d 被信号 %d 杀死\n",
                   child_pid, WTERMSIG(status));
        }
    }
    printf("父进程回收完成,所有子进程已结束\n");
    return 0;
}

运行效果:
在这里插入图片描述

  • 父进程创建 3 个子进程
  • 子进程各自工作后正常结束
  • 父进程循环 waitpid 回收所有子进程
  • 看到每个子进程的 PID 和退出码
  • 无僵尸进程,程序干净结束
3.3.3 小结与最佳实践

return 在子进程中本质上会像 exit 一样刷新缓冲区,但只要养成‘printf 带 \n’或 fflush 的好习惯,重复输出的风险几乎为零,所以 return 仍然是最推荐的退出方式;_exit 只作为防呆(不知道有没有加\n或者代码有没有不知道的未刷新的缓冲)备用方案。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐