进程被创建后,并不会一直占用 CPU。它可能在运行队列中等待调度,也可能因为定时器、终端输入、磁盘 I/O 或信号而暂时停止。本文从 task_struct 和内核队列出发,梳理 Linux 常见进程状态,并用三个小实验观察状态是怎样一步步变化的。


0. 先看主线:状态是怎样变化的

观察一个进程时,可以先问四个问题:

  1. 任务现在是否具备运行条件?具备时通常看见 R;
  2. 如果不能运行,它在等什么?普通可中断事件通常是 S,关键内核等待可能是 D;
  3. 它是不是被人为停止了?作业控制停止是 T,调试跟踪停止是 t;
  4. 它是不是已经结束,只剩退出记录?是的话就是 Z。

先从进程创建开始。内核创建 task_struct,任务具备运行条件后进入运行队列,此时 ps 通常显示 R:

创建 task_struct → 具备运行条件 → 进入运行队列 → R

任务在执行过程中需要等待事件,就会离开可运行集合;条件满足后再被唤醒:

R ──等待定时器、输入或 I/O──> S / D
R <──────事件完成、任务被唤醒────── S / D

如果任务被信号或调试器停止,变化过程是:

R / S ──SIGSTOP 或调试器──> T / t
R     <──SIGCONT 或继续调试── T / t

最后,进程结束执行与父进程回收记录是两个动作:

正在执行 ──exit / _exit / 终止信号──> Z
Z ─────────父进程 wait / waitpid────────> 表项释放

教材中的创建、就绪、运行、阻塞和终止是一套通用模型,Linux 的 ps 状态码是另一套观察视角。两者可以建立联系,但不能逐字母机械对应:教材里的“就绪”和“正在运行”在 ps 中通常都显示为 R;教材里的“挂起”也不等于 ps 中的 T。

一眼速查

ps 主状态 含义 下一步怎样确认
R 正在运行,或已经在可运行集合中等待 CPU 看 CPU 占用、调度行为,避免把 R 直接等同于“正在占 CPU”
S 可中断睡眠,常见于定时器、管道、终端、网络和同步等待 WCHAN、系统调用和程序是否本来就在等待
D 不可中断睡眠,常见于内核 I/O 或关键资源等待 WCHAN、内核栈、设备与系统日志,不要只反复 kill -9
T 作业控制信号导致的停止 检查 SIGSTOPSIGTSTP,用 SIGCONT 恢复
t 调试器或 ptrace 导致的跟踪停止 检查 GDB、strace 或其他跟踪者
Z 子进程已经退出,但退出记录尚未被父进程读取 找 PPID,检查父进程是否调用 wait()/waitpid()
X 内核死亡状态,通常很难被 ps 采样到 一般不把它当作日常排障入口
I 空闲内核线程 先确认它是不是内核线程

旧资料中的 W 表示 paging,但当前 ps 手册已经注明它从 Linux 2.6 起不再有效。


1. 从 task_struct 和队列理解状态

理解进程状态,需要先明确内核实际管理的对象:

进程的用户态内容:代码、数据、堆、栈、映射……
                    ↑
内核管理入口:task_struct
                    ↓
PID、父子关系、调度信息、信号、打开文件、内存描述、状态……

CPU 调度时不会把源代码或整个地址空间塞进队列,调度器组织的是代表任务的内核对象。能运行的任务进入可运行集合;等待某个事件的任务进入相应等待结构;退出后的任务还可能以最小记录等待父进程回收。

从队列关系可以推出什么

  • R 不保证采样瞬间一定占用 CPU。只要任务具备运行条件并处于调度器的可运行集合,就可能显示为 R;
  • 阻塞不是“进程消失”,而是任务暂时离开可运行集合,被组织到与事件相关的等待结构;
  • 状态变化通常意味着调度资格或等待关系发生了变化;
  • 一个 task_struct 同时参与父子关系、进程组、调度和等待等多种组织关系,真实内核并不是只靠一条普通链表管理所有进程。

内核常在结构体中嵌入 struct list_head 等节点,再由节点找到所属对象。这种侵入式组织方式解释了为什么同一个任务可以同时处于多种管理关系中。

可以用一句话概括:

进程状态的本质,不是 PCB 上孤立的一个字母,而是任务当前是否可调度、在等待什么,以及它被组织在哪里。


2. 两套语言不要混:教材状态模型与 Linux 状态码

教材模型回答的是“进程生命周期如何抽象”;Linux ps 回答的是“当前内核把任务显示成什么状态”。先区分层次,再建立映射:

教材概念 Linux 中常见观察 不能忽略的边界
就绪/运行 R ps 不区分“正在 CPU 上”还是“排队等 CPU”
阻塞 S 或 D S 与 D 的关键信号响应语义不同
停止 T 或 t T 来自作业控制,t 来自跟踪机制
终止 短暂退出流程,或 Z Z 不是还在执行,而是退出记录未回收
挂起/换出 页面可能进入 swap 不存在一个可直接等同的现代用户进程 ps 字母

三个容易混淆的边界

  1. R = running or runnable,不能只翻译成“正在运行”;
  2. D 不是永远杀不掉。信号可能保持待处理,任务离开不可中断区域后,内核才有机会完成终止;
  3. 普通清理先用 SIGTERMkill PID 默认发送 SIGTERMkill -9 PID 发送不可捕获的 SIGKILL,不应该成为第一反应。

3. 用 ps 观察状态:不要只看一个 STAT 字母

排查进程状态时,可以先使用下面的命令:

ps -o pid,ppid,pgid,tpgid,tty,stat,wchan,cmd -p <PID>

各列的关注点:

关注什么
PID / PPID 当前任务是谁,父进程是谁
PGID 所属进程组,Shell 管道中的多个进程往往共享 PGID
TPGID 当前终端前台进程组 ID
TTY 是否关联控制终端
STAT 第一个字符是主状态,后续字符是附加属性
WCHAN 非 R 状态时,任务大致在内核哪个等待点休眠
CMD 实际命令与参数,避免看错目标进程

STAT 中常见附加字符:

附加字符 含义
+ 位于终端的前台进程组
s 会话首进程
l 多线程进程
< 高优先级
N 低优先级

为什么后台程序通常收不到当前终端的 Ctrl+C

终端产生的 SIGINT 发给前台进程组,而不是“发给终端里所有进程”。因此理解前后台时,需要结合 PGID、TPGID 和 STAT 中的 +,不能只看命令末尾有没有 &

需要结束后台任务时,通常先:

kill <PID>        # SIGTERM

程序拒绝退出并且已经确认无法正常清理时,才考虑:

kill -9 <PID>     # SIGKILL

4. 用一个程序观察 R、S、T

4.1 最小实验程序

#define _POSIX_C_SOURCE 200809L

#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc != 2 ||
        (strcmp(argv[1], "busy") != 0 && strcmp(argv[1], "sleep") != 0)) {
        fprintf(stderr, "usage: %s <busy|sleep>\n", argv[0]);
        return 1;
    }

    printf("pid=%ld, mode=%s\n", (long)getpid(), argv[1]);
    fflush(stdout);

    if (strcmp(argv[1], "sleep") == 0) {
        for (;;) {
            sleep(1);
        }
    }

    volatile unsigned long long counter = 0;
    for (;;) {
        ++counter;
    }
}

4.2 运行结果

请添加图片描述

同一份程序通过计算、睡眠和信号控制依次呈现 R、S、T、S。PID 每次运行都会变化。

4.3 如何解释实验结果

现象 机制
busy 容易采样到 R 循环几乎不主动等待资源,任务持续具备运行条件
sleep 容易采样到 S 任务在等待定时器到期,可被合适的信号打断
SIGSTOP 后变为 T 停止信号撤销了任务继续执行的资格
SIGCONT 后又看到 S 继续信号恢复执行,但程序马上又进入 sleep()
状态后一直有 + 前台进程组没有改变;+ 不是主状态的一部分

如果忙等程序里不断 printf(),反而可能采样到 S,因为终端输出本身涉及 I/O。设计状态实验时,应尽量去掉会改变目标状态的额外操作。


5. S 与 D:都在等,但可中断语义不同

S:可中断睡眠

看到 S 时,不应该马上判断“程序卡死”,而应该先确认它是否本来就在等待事件。

典型来源包括:

  • sleep() 等待定时器;
  • 管道、终端、套接字暂时没有数据;
  • 等待锁、条件变量或其他同步事件;
  • 交互程序等待输入。

S 很常见,大量 S 通常并不意味着系统异常。它表示任务暂时没有可推进的工作,并且等待可以被合适的信号打断。

D:不可中断睡眠

D 常见于任务已经进入内核,正在等待某些 I/O、设备或关键资源。它与 S 的重要区别是:普通信号到达后,等待不会立刻被打断。

不能简单地说“D 状态绝对杀不掉”。更准确的过程是:

信号到达
   ↓
可能先保持 pending
   ↓
内核等待条件结束,任务离开不可中断区域
   ↓
内核才有机会完成信号对应动作

看到长期 D 时的排查顺序:

ps -o pid,ppid,stat,wchan,cmd -p <PID>
cat /proc/<PID>/stack       # 通常需要足够权限
dmesg --ctime | tail

接着结合磁盘、网络文件系统、块设备、驱动和系统日志判断等待原因。反复发送 SIGKILL 不会修复底层 I/O;如果等待条件始终无法完成,真正的问题往往在设备、驱动或远程资源。


6. T 与 t:都停止,但停止者不同

状态 谁造成的 典型场景 如何恢复
T 作业控制信号 SIGSTOP、终端 Ctrl+Z 产生的 SIGTSTP SIGCONT 或 Shell 的 fg/bg
t 调试或跟踪机制 GDB 断点、单步、ptrace 跟踪停止 让调试器继续或退出跟踪

需要记住:SIGSTOP 不能被捕获或忽略,SIGTSTP 可以被程序处理;两者都可能让 ps 显示 T,但语义并不完全相同。


7. Z:退出和回收是两个动作

先区分“退出”和“回收”:

exit/_exit/被信号终止:结束执行,释放大部分运行资源
wait/waitpid:父进程读取退出记录,内核删除剩余表项

7.1 为什么必须暂时保留记录

父进程可能需要知道子进程:

  • 是正常 return/exit(),还是被信号终止;
  • 正常退出时返回了什么退出码;
  • 消耗了多少 CPU 时间等资源统计。

因此子进程退出后,地址空间、打开文件等大部分资源会释放,但 PID、退出状态和少量内核记账信息会暂时留下。父进程不调用 wait()/waitpid() 时,这条退出记录表现为 Z。

7.2 实验代码和结果

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }

    if (child == 0) {
        printf("child : pid=%ld, ppid=%ld, exit=42\n",
               (long)getpid(), (long)getppid());
        _exit(42);
    }

    printf("parent: pid=%ld, child=%ld, wait after 4 seconds\n",
           (long)getpid(), (long)child);
    sleep(4);  /* 留出观察 Z 的窗口 */

    int status = 0;
    if (waitpid(child, &status, 0) < 0) {
        perror("waitpid");
        return 1;
    }

    if (WIFEXITED(status)) {
        printf("parent: reaped child=%ld, exit=%d\n",
               (long)child, WEXITSTATUS(status));
    }

    return 0;
}

在这里插入图片描述

图 2:观察窗口内子进程显示为 Z+<defunct>;父进程随后用 waitpid() 取得退出码 42。

7.3 关于 Z 的几个边界

  • 僵尸进程已经不能执行代码,对它发送信号并不能让它“再死一次”;
  • 它不再持有原来的完整用户态地址空间和打开文件,主要保留 PID、进程表项和退出记账;
  • 短暂 Z 是正常退出流程的一部分,长期、大量 Z 才说明父进程回收逻辑有问题;
  • 危害更准确地说是进程表/PID 等进程资源泄漏,而不是普通堆内存泄漏。

快速寻找僵尸及其父进程:

ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'

排查重点应放在 PPID 对应的父进程:它是否忽略了 SIGCHLD 处理、漏掉了某些子进程,或者没有正确循环调用 waitpid()


8. 孤儿进程:变化的是父子关系,不是主状态

孤儿进程的定义只需要一句话:父进程先退出,而子进程仍在运行。

它不自动等于后台进程,也不自动变成 Z。子进程原来是 R、S、D 还是 T,重新托管后仍可能保持相应状态。

8.1 实验代码和结果

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }

    if (child == 0) {
        printf("child before: pid=%ld, ppid=%ld\n",
               (long)getpid(), (long)getppid());
        sleep(4);
        printf("child after : pid=%ld, ppid=%ld\n",
               (long)getpid(), (long)getppid());
        sleep(1);
        return 0;
    }

    printf("parent: pid=%ld, child=%ld, exit after 2 seconds\n",
           (long)getpid(), (long)child);
    sleep(2);
    printf("parent: exiting now\n");
    return 0;
}

在这里插入图片描述

图 3:父进程退出前后,子进程一直处于 S;改变的是 PPID 和未来的回收责任。

8.2 为什么新 PPID 不一定是 1

入门资料通常把它简化为“孤儿进程由 PID 1 收养”,但现代 Linux 还支持 child subreaper。更准确的规则是:

孤儿进程重新托管给最近的、仍然存活的祖先 subreaper;没有合适的 subreaper 时,才交给 PID 1。

systemd、容器运行环境、进程管理框架和 WSL 宿主机制都可能参与这件事。本次 WSL 实验里,新 PPID 不是 1,正好说明实际环境中存在上层收割者。

8.3 僵尸与孤儿必须一眼分开

对比项 僵尸进程 孤儿进程
子进程是否退出 已退出 通常仍在运行
原父进程 仍在,但尚未回收 已退出
能否继续执行代码 不能
ps 主状态 Z 仍可能是 R、S、D、T 等
核心问题 退出记录无人读取 回收责任需要重新托管
处理机制 wait()/waitpid() subreaper 或 PID 1 接管

可以用一句话区分:

僵尸是子进程先结束但父进程没收尾;孤儿是父进程先结束但子进程还要继续。


9. 换页、挂起、停止不要混在一起

概念 所属层次 含义
页面换出到 swap 内存管理 进程的部分代码页或数据页暂时不在物理内存
教材中的挂起 通用进程模型 描述任务暂时不参与正常调度,可能与换出结合
T/t Linux 可观察状态 任务因为作业控制或跟踪机制停止执行
S/D Linux 等待状态 任务等待事件或关键内核条件

task_struct 等关键内核管理信息仍然需要保留,否则内核无法知道任务是谁、拥有什么资源、随后怎样恢复执行。因此“页面被换出”不等于“整个进程从内核消失”,也不等于 ps 必然显示 T。


10. 常见误区

容易写错的说法 准确理解
R 就是正在使用 CPU R 还包括 runnable,即具备运行条件但正在排队
S 说明程序异常卡住 S 经常只是正常等待定时器、输入、网络或同步事件
D 永远不能杀死 信号可能先 pending,离开不可中断区域后才有机会处理
kill 就是强杀 kill PID 默认是 SIGTERMkill -9 才是 SIGKILL
STAT 中的 + 是状态 + 是前台进程组标记,主状态仍是第一个字符
T 和 t 完全相同 T 是作业控制停止,t 是调试/跟踪停止
僵尸仍占完整内存 大部分运行资源已释放,主要保留退出记录和进程表资源
给僵尸发送 SIGKILL 就能清理 僵尸已经结束,应该让父进程执行 wait 系列调用
孤儿必然变成后台进程 父子关系变化不自动改变会话、进程组和控制终端
孤儿的新 PPID 一定是 1 可能先托管给最近的存活 subreaper
swap 就是 T 状态 换页属于内存管理,T 属于执行停止
W 是常见现代 Linux 状态 W 从 Linux 2.6 起已经失效

11. 总结

只记住“R 是运行、S 是睡眠、Z 是僵尸”还不够。进程状态可以分成三个层次理解:

  1. 管理层: 内核以 task_struct 为入口,通过调度队列、等待结构和父子关系组织任务;
  2. 现象层: ps 的状态字符只是当前关系的一个可见结果,需要结合 PPID、PGID、TPGID、WCHAN 和程序行为解释;
  3. 验证层: 用忙等、sleep()SIGSTOPwaitpid() 和父进程提前退出,可以亲手复现主要状态和关系变化。

状态字符只是现象,队列关系和父子关系才是原因。能够根据程序行为解释 ps 输出,并用实验验证每次状态变化,才算真正理解这一部分内容。


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐