
Linux 七大进程状态深度解析
内核源码中的状态定义
进程状态存储在 task_struct 的 state 和 exit_state 字段中,定义于 include/linux/sched.h:
struct task_struct {
volatile long state; /* 运行中状态:-1 不可运行, 0 可运行, >0 停止 */
int exit_state; /* 退出中状态:EXIT_ZOMBIE / EXIT_DEAD */
// …
};
具体状态标志:
/* tsk->state 使用 */
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define TASK_STOPPED 0x0004
#define TASK_TRACED 0x0008
/* tsk->exit_state 使用 */
#define EXIT_ZOMBIE 0x0010
#define EXIT_DEAD 0x0020
关键区分:state 字段管理进程"活着"时的状态(R/S/D/T/t),
exit_state 字段管理进程"退出过程中"的状态(Z/X)。
两者互斥使用。
一、R — TASK_RUNNING(运行态 / 就绪态)
定义
#define TASK_RUNNING 0x0000
值为 0,是所有状态中唯一表示"可以被调度执行"的状态。
语义拆解
TASK_RUNNING 实际上包含了传统操作系统中的两个子状态:
| 运行中(Running) | 进程正在 CPU 上执行指令 |
| 就绪(Ready) | 进程已准备好,在运行队列(runqueue)中等待被调度 |
Linux 不区分这两者,统一用 TASK_RUNNING 表示。
区别仅在于:该进程的 task_struct 是否正在被某个 CPU 的 current 指针指向。
进入 R 态的触发条件
- 进程被 fork 创建后,初始即为 TASK_RUNNING
- 睡眠进程等待的事件发生,被 wake_up() 唤醒
- 停止态进程收到 SIGCONT 信号
- 时间片耗尽后被调度器换下 CPU,但仍保持 R 态(在就绪队列中)
离开 R 态的触发条件
- 主动调用 schedule() 放弃 CPU(如等待某资源)
- 时间片耗尽,被调度器抢占(但仍在 R 态就绪队列中,不算离开)
- 收到停止信号进入 T 态
- 调用 exit() 退出
运行队列与调度
所有处于 TASK_RUNNING 的进程都挂在每 CPU 运行队列(struct rq)上。
CFS 调度器从红黑树中挑选 vruntime 最小的进程执行。
CPU 0 的运行队列(红黑树,按 vruntime 排序):
[P3: vruntime=100]
/ \\
[P1: vruntime=80] [P5: vruntime=150]
/
[P2: vruntime=120]
调度器选择最左侧节点(vruntime 最小)→ P1 上 CPU 执行
实际场景
# 查看正在运行的进程
ps aux | awk '$8 ~ /R/'
一个 CPU 密集型程序(如死循环计算)会长期处于 R 态:
while (1) {
// 纯计算,不调用任何阻塞函数
result += compute_something();
}
二、S — TASK_INTERRUPTIBLE(可中断睡眠态)
定义
#define TASK_INTERRUPTIBLE 0x0001
语义
进程在等待某个事件发生,主动放弃 CPU 进入睡眠。
但它可以被信号唤醒——当一个信号递达时,进程会被唤醒去处理信号,即使等待的事件尚未发生。
进入 S 态的典型场景
| sleep(10) | 定时器未到期前睡眠 |
| read() 阻塞终端/网络 | 等待数据到达 |
| wait() / waitpid() | 等待子进程退出 |
| pause() | 等待任意信号 |
| 等待互斥锁/信号量 | 锁被其他进程持有 |
| select() / poll() / epoll_wait() | 等待 I/O 事件 |
内核中的睡眠机制
进程进入可中断睡眠的标准模式:
// 定义等待队列节点
DEFINE_WAIT(wait);
// 将自己加入等待队列,并设置状态
prepare_to_wait(&wq, &wait, TASK_INTERRUPTIBLE);
if (!condition) {
schedule(); // 主动调度,放弃 CPU
}
// 被唤醒后,从等待队列移除
finish_wait(&wq, &wait);
唤醒路径有两条:
被信号唤醒后的行为
进程被信号唤醒后,通常会检查是否有信号待处理。如果有,系统调用(如 read)会返回 -EINTR,表示"被信号中断":
ssize_t ret = read(fd, buf, size);
if (ret == -1 && errno == EINTR) {
// 被信号中断,可以选择重试
continue;
}
这就是为什么很多健壮的代码会在循环中重试被 EINTR 中断的系统调用。
实际场景
绝大多数后台服务进程在空闲时都处于 S 态——它们在等待客户端请求、等待 I/O 完成。
# 统计 S 态进程数量(通常占绝大多数)
ps aux | awk '$8 ~ /^S/' | wc -l
三、D — TASK_UNINTERRUPTIBLE(不可中断睡眠态)
定义
#define TASK_UNINTERRUPTIBLE 0x0002
语义
进程在等待不可中断的事件,通常是底层 I/O 操作(磁盘读写、NFS 挂载等)。
与 S 态的核心区别:不能被信号唤醒,只能由等待的事件完成来唤醒。
为什么需要不可中断?
这是最常被问到的面试点。假设进程正在执行磁盘 read:
如果此时允许信号中断这个进程:
- 进程可能在 I/O 完成前就退出或处理信号
- 内核态的 I/O 操作可能处于不一致状态
- 已发出的磁盘请求无法干净地回滚
- 可能导致数据损坏或内核崩溃
因此,在 I/O 操作的关键路径上,进程必须设为不可中断睡眠,确保 I/O 完成前进程不会被干扰。
进入 D 态的典型场景
| 磁盘 read / write | 等待物理 I/O 完成 |
| fsync / fdatasync | 等待数据刷入磁盘 |
| NFS 网络文件系统操作 | 等待网络 I/O,网络不通时可能长时间 D 态 |
| 直接 I/O(O_DIRECT) | 绕过页缓存,直接等待磁盘 |
| 某些设备驱动操作 | 如磁带机、原始设备访问 |
D 态与系统负载
D 态进程会被计入系统负载(load average)。
这是一个重要的知识点:
Linux 的 load average 不仅统计 R 态进程,还统计 D 态进程。
因此,当系统 load 很高但 CPU 使用率很低时,往往意味着大量进程处于 D 态——磁盘 I/O 是瓶颈。
# 查看 D 态进程
ps aux | awk '$8 ~ /D/'
# 用 iostat 确认磁盘瓶颈
iostat -x 1
D 态能被杀死吗?
不能。 因为 D 态进程不响应信号,kill -9(SIGKILL)也无法终止它。进程会一直保持 D 态,直到:
这也是 D 态进程令人头疼的原因——如果磁盘或 NFS 出现故障,D 态进程可能永远卡在那里,唯一的解决办法是修复 I/O 子系统或重启。
内核中的 io_schedule()
进入不可中断睡眠通常使用 io_schedule():
// 设置为不可中断睡眠并调度
set_task_state(tsk, TASK_UNINTERRUPTIBLE);
io_schedule(); // 内部调用 schedule(),并统计 iowait 时间
io_schedule() 与普通 schedule() 的区别在于:它会将等待时间计入 iowait(/proc/stat 中的 iowait 字段),这也是 top 中 %wa 的来源。
四、T — TASK_STOPPED(停止态)
定义
#define TASK_STOPPED 0x0004
语义
进程被暂停执行,既不在 CPU 上运行,也不在等待任何事件。它完全"冻结",直到收到恢复信号。
进入 T 态的方式
| SIGSTOP 信号 | 不可被捕获/忽略/阻塞,强制停止进程 |
| SIGTSTP 信号 | 终端 Ctrl+Z 发送,可被捕获 |
| SIGTTIN 信号 | 后台进程尝试读取终端时 |
| SIGTTOU 信号 | 后台进程尝试写入终端时 |
| ptrace 附加 | 调试器附加时,目标进程进入停止态 |
从 T 态恢复
只有一个方式:收到 SIGCONT 信号。
# 停止一个进程
kill -STOP 1234
# 查看状态(应为 T)
ps -o pid,stat,cmd -p 1234
# 恢复进程
kill -CONT 1234
终端作业控制中的 T 态
Ctrl+Z 是最常见的触发场景:
$ sleep 100
# 按下 Ctrl+Z
^Z
[1]+ Stopped sleep 100
$ jobs
[1]+ Stopped sleep 100
$ bg %1 # 后台继续运行(发送 SIGCONT)
[1]+ sleep 100 &
$ fg %1 # 前台恢复运行
sleep 100
T 态进程的资源
处于 T 态的进程:
- 保留所有内存资源(不会被换出,但可以被回收)
- 保留文件描述符
- 不在运行队列中,不会被调度
- 不响应除 SIGCONT 外的大多数信号(但信号会被记录为挂起)
注意:SIGKILL 可以终止 T 态进程吗?
可以。SIGKILL 对 T 态进程有效,进程会直接进入退出流程。但其他常规信号(如 SIGTERM)在 T 态下不会被立即处理,要等进程恢复到 R/S 态后才会递达。
五、t — TASK_TRACED(追踪态)
定义
#define TASK_TRACED 0x0008
语义
进程被调试器追踪时的暂停状态。当 gdb 等调试器通过 ptrace 附加到进程,并命中断点或单步执行时,目标进程进入 TASK_TRACED。
与 T 态(STOPPED)的区别
这是一个高频面试考点:
| 触发方式 | SIGSTOP / SIGTSTP / Ctrl+Z | ptrace 断点 / 单步 |
| 恢复方式 | SIGCONT 信号 | 调试器调用 ptrace(PTRACE_CONT) |
| 能否被 SIGCONT 唤醒 | 能 | 不能(必须由 ptrace 恢复) |
| 信号处理 | 挂起信号等待恢复 | 调试器可拦截信号 |
| ps 显示 | T | t(小写) |
ptrace 与 TRACED 态
ptrace 是 Linux 提供的进程追踪机制,gdb、strace、ltrace 等工具都基于它。
// 父进程(调试器)追踪子进程
pid_t child = fork();
if (child == 0) {
// 子进程:允许被追踪
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "ls", NULL);
} else {
// 父进程:等待子进程事件
int status;
waitpid(child, &status, 0);
// 设置断点、单步执行等
ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
// 子进程执行一条指令后进入 TASK_TRACED
}
当子进程命中断点时,内核会发送 SIGTRAP 信号,子进程进入 TASK_TRACED,父进程通过 waitpid 获知。
实际场景
# 用 gdb 调试一个程序
gdb ./myprogram
(gdb) break main
(gdb) run
# 另一个终端查看
ps aux | grep myprogram
# wxx 7800 0.0 0.1 … t 10:30 0:00 ./myprogram
# ↑ 小写 t,表示 TRACED 态
六、Z — EXIT_ZOMBIE(僵尸态)
定义
#define EXIT_ZOMBIE 0x0010
注意这是定义在 exit_state 字段中,而非 state 字段。
语义
进程已经执行完毕,释放了用户态资源(内存、文件描述符等),但内核中的 task_struct 尚未释放。它像一具"尸体"一样留在进程表中,等待父进程来"收尸"。
僵尸进程的产生流程
1. 子进程调用 exit() 或从 main 返回
↓
2. 内核释放子进程的用户态资源(地址空间、文件描述符、信号处理等)
↓
3. 子进程的 exit_state 设为 EXIT_ZOMBIE
↓
4. 内核向父进程发送 SIGCHLD 信号
↓
5. 子进程保留 task_struct(包含退出码、资源使用统计等信息)
↓
6. 父进程调用 wait()/waitpid() 读取子进程退出信息
↓
7. 内核释放子进程的 task_struct → 进入 EXIT_DEAD
如果步骤 6 从未发生,子进程就永远停留在僵尸态。
为什么需要僵尸态?
这是面试高频问题。僵尸态存在的意义是:
保存子进程的退出状态和资源使用信息,供父进程查询。
父进程可能想知道:
- 子进程是正常退出还是被信号杀死?
- 退出码是多少?
- 子进程累计使用了多少 CPU 时间?
- 子进程的最大内存驻留集是多少?
这些信息存储在 task_struct 中,必须等父进程通过 wait() 读取后才能释放。如果子进程一退出就完全销毁,父进程就无法获取这些信息了。
代码演示
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
// 子进程:立刻退出
printf("【子进程】PID=%d,即将退出\\n", getpid());
exit(42); // 退出码 42
} else {
// 父进程:不调用 wait,睡眠 60 秒
printf("【父进程】PID=%d,子进程 PID=%d,不回收,睡眠 60 秒\\n",
getpid(), pid);
sleep(60);
}
return 0;
}
运行后另开终端:
ps aux | grep -E 'Z|defunct'
# wxx 8101 0.0 0.0 0 0 pts/0 Z 10:35 0:00 [zombie_demo] <defunct>
注意僵尸进程的 VSZ 和 RSS 都是 0——用户态内存已释放,只剩内核数据结构。
僵尸进程的危害
| 占用 PID | 每个僵尸进程占用一个 PID 号,系统 PID 有限(默认 32768) |
| 占用内核内存 | 每个 task_struct 约 1.7KB,数量多时不可忽视 |
| 无法直接杀死 | kill 对僵尸进程无效,因为它已经"死了" |
| 影响进程创建 | PID 耗尽后,fork 会失败返回 EAGAIN |
如何处理僵尸进程
方法一:父进程调用 wait() / waitpid()
int status;
waitpid(pid, &status, 0); // 阻塞等待指定子进程
// 或
wait(&status); // 等待任意子进程
方法二:SIGCHLD 信号处理(非阻塞回收)
void sigchld_handler(int sig) {
int saved_errno = errno;
// WNOHANG:不阻塞,没有已退出的子进程则立即返回
while (waitpid(-1, NULL, WNOHANG) > 0);
errno = saved_errno;
}
int main() {
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
// …
}
为什么用 while 循环?
因为多个子进程可能同时退出,而信号不排队,一次 SIGCHLD 可能对应多个子进程退出。
方法三:父进程先退出,让 init 收养
如果父进程先于子进程退出,子进程会被 init(PID 1)收养。init 有一个专门的 SIGCHLD 处理函数,会自动回收所有孤儿进程,不会产生僵尸。
方法四:两次 fork(高级技巧)
pid_t pid = fork();
if (pid == 0) {
// 第一个子进程
pid_t grandchild = fork();
if (grandchild == 0) {
// 孙子进程:执行实际任务
execvp(…);
}
// 第一个子进程立即退出,孙子进程被 init 收养
exit(0);
}
// 父进程等待第一个子进程(很快退出)
waitpid(pid, NULL, 0);
孙子进程的父进程(第一个子进程)立即退出,孙子被 init 收养,自动回收,父进程也无需长期等待。
如何"杀死"一个已经存在的僵尸进程
僵尸进程无法被直接杀死,因为它已经退出了。唯一的方法是:
# 找到僵尸进程的父进程 PID
ps -o ppid= -p <zombie_pid>
# 杀死父进程(谨慎!)
kill <ppid>
七、X — EXIT_DEAD(死亡态)
定义
#define EXIT_DEAD 0x0020
同样定义在 exit_state 字段中。
语义
进程的最终状态。父进程已经通过 wait() 回收了子进程的退出信息,内核即将释放 task_struct。这个状态极其短暂——通常只持续几个指令周期,ps 命令几乎不可能捕捉到。
进入 X 态的流程
父进程调用 waitpid()
↓
内核调用 release_task()
↓
将子进程 exit_state 设为 EXIT_DEAD
↓
从进程表中移除(PID 可重用)
↓
释放 task_struct 内核栈
↓
释放其他剩余内核资源
↓
进程彻底消失
为什么需要 X 态?
既然 Z 态之后进程就要被释放了,为什么还要一个中间的 X 态?
原因是并发安全。在多 CPU 系统中,释放 task_struct 的过程不是原子的。
设置 EXIT_DEAD 标志可以:
// kernel/exit.c 中的 release_task()
void release_task(struct task_struct *p)
{
// …
p->exit_state = EXIT_DEAD; // 标记为死亡态
// … 执行释放操作
call_rcu(&p->rcu, delayed_put_task_struct); // 延迟释放
}
X 态的可见性
# 几乎不可能看到 X 态进程
ps aux | awk '$8 ~ /X/'
# 通常无输出
如果你真的看到了 X 态进程,那可能是:
- 系统处于极高负载,释放操作被延迟
- 内核存在 bug
- 你在 ps 输出刷新的瞬间恰好捕捉到了
状态转换全景图
fork()
新建 ───────────────────────────→ R (TASK_RUNNING)
│
┌───────────────────┼───────────────────┐
│ │ │
等待事件/资源 时间片到 收到停止信号
(可中断) (仍在就绪队列) (SIGSTOP/Ctrl+Z)
│ │ │
▼ ▼ ▼
S (可中断睡眠) R T (停止态)
D (不可中断睡眠) │
│ │
事件完成/I/O完成 SIGCONT
(D态只能被事件唤醒) │
(S态还可被信号唤醒) │
│ │
└───────────────────┬───────────────────┘
│
▼
R
│
┌────────────────────┼────────────────────┐
│ │ │
ptrace断点 exit()/信号 正常执行
│ │ │
▼ ▼ │
t (追踪态) Z (僵尸态) │
│ │ │
ptrace CONT 父进程 wait() │
│ │ │
└────────────────────┼────────────────────┘
│
▼
X (死亡态)
│
▼
资源释放,消失
补充知识
1. 进程状态与 load average 的关系
Linux 的 load average 统计的是处于不可中断睡眠(D)和运行/就绪(R)状态的进程数的指数衰减平均值。
load average = 正在运行的进程(R) + 等待I/O的进程(D)
这解释了一个经典问题:为什么 CPU 使用率很低,但 load average 很高?
→ 因为大量进程在 D 态等待磁盘 I/O,它们被计入 load 但不消耗 CPU。
2. ps 中 STAT 列的附加字符
除了基本状态字符,ps 的 STAT 列还可能包含附加修饰符:
| s | 会话首进程(session leader),通常是 shell |
| l | 多线程进程 |
| + | 位于前台进程组 |
| < | 高优先级(nice 值为负) |
| N | 低优先级(nice 值为正) |
| L | 有页面锁定在内存中(mlock) |
例如 Ssl+ = 可中断睡眠 + 会话首进程 + 多线程 + 前台进程组。
3. 内核线程的状态
内核线程(如 kthreadd、kworker、migration)也使用相同的状态机。你在 ps 中看到的 [kworker/0:0] 这类带方括号的进程就是内核线程。它们通常处于 S 态(等待工作)或 R 态(处理工作)。
4. 空闲进程的状态
每个 CPU 都有一个空闲进程(idle task,PID 0),当没有其他 R 态进程时运行。空闲进程不占用进程表项,也不在 ps 中显示。
高频面试题
Q1:Linux 有哪几种进程状态?分别是什么含义?
参考答案:
- R(Running):运行或就绪,在 CPU 上执行或在运行队列等待调度
- S(Sleeping):可中断睡眠,等待事件,可被信号唤醒
- D(Disk sleep):不可中断睡眠,通常等待 I/O,不能被信号唤醒
- T(Stopped):停止态,被 SIGSTOP 等信号暂停,SIGCONT 恢复
- t(Traced):追踪态,被调试器暂停,由 ptrace 恢复
- Z(Zombie):僵尸态,进程已退出但父进程未回收,保留 task_struct
- X(Dead):死亡态,父进程已回收,task_struct 即将释放
Q2:D 态和 S 态有什么区别?为什么需要 D 态?
参考答案:
- S 态可被信号唤醒,D 态不能被信号唤醒
- D 态用于保护内核态关键操作(主要是 I/O)的完整性。如果在磁盘 I/O 过程中允许信号中断,可能导致 I/O 操作处于不一致状态,引发数据损坏
- D 态进程会被计入 load average,S 态不会
- kill -9 无法杀死 D 态进程
Q3:僵尸进程是怎么产生的?如何避免和处理?
参考答案:
- 产生:子进程退出后,父进程未调用 wait()/waitpid() 回收,子进程的 task_struct 残留在进程表中
- 避免:
- 父进程显式调用 wait() 或 waitpid()
- 注册 SIGCHLD 信号处理函数,在其中用 waitpid(-1, NULL, WNOHANG) 循环回收
- 父进程先退出,子进程被 init 收养自动回收
- 两次 fork,让孙子进程被 init 收养
- 处理已存在的僵尸:杀死其父进程,让 init 收养并回收
Q4:T 态和 t 态有什么区别?
参考答案:
- T 态由 SIGSTOP/SIGTSTP/Ctrl+Z 触发,可被 SIGCONT 恢复
- t 态由 ptrace 调试触发(断点、单步),只能由调试器通过 ptrace(PTRACE_CONT) 恢复,SIGCONT 无效
- T 态进程的信号会被挂起,t 态进程的信号可被调试器拦截和修改
Q5:为什么 load average 高但 CPU 使用率低?
参考答案:
- load average 统计的是 R 态 + D 态进程数
- D 态进程在等待 I/O(通常是磁盘),不消耗 CPU 但计入 load
- 这种情况说明系统的瓶颈在磁盘 I/O,而非 CPU
- 可用 iostat -x、iotop 排查磁盘瓶颈,用 ps aux | awk '$8 ~ /D/' 查看 D 态进程
Q6:kill -9 能杀死哪些状态的进程?不能杀死哪些?
参考答案:
- 能杀死:R、S、T、t、Z(僵尸进程本就已死,kill 无意义但不报错)
- 不能杀死:D 态(不可中断睡眠,不响应任何信号,包括 SIGKILL)
- X 态瞬间即逝,不存在杀死的问题
Q7:fork 之后子进程的初始状态是什么?
参考答案:
- TASK_RUNNING。子进程被创建后直接加入运行队列,等待被调度执行。
- 但子进程是否立即运行取决于调度器,父子进程的执行顺序不确定。
Q8:进程退出的完整流程是什么?
参考答案:
总结
Linux 的七大进程状态构成了一个完整的生命周期状态机:
- R 是进程"活着且能动"的状态
- S/D 是进程"睡着等事件"的状态,区别在于能否被信号打断
- T/t 是进程"被暂停"的状态,区别于是被信号还是调试器暂停
- Z/X 是进程"已死"的状态,区别在于是否已被父进程回收
理解这些状态不仅能帮助你写出更健壮的代码(正确处理 EINTR、避免僵尸进程),更是排查线上问题(load 高、进程卡死、系统响应慢)的必备知识。在面试中,进程状态也是操作系统和 Linux 系统编程方向的高频考点。



