本文为 Linux 系统编程系列的第二篇,深入讲解进程状态的核心概念(运行、阻塞、挂起),结合 Linux 内核的具体实现(R、S、D、T、t、X、Z 状态),并通过实验演示僵尸进程的形成与危害,最后简要介绍孤儿进程。同时,讲解内核中如何通过 **list_head**实现 PCB 的多数据结构组织,为后续进程调度打下基础。
一、课前复习:进程的本质与 PCB
在正式进入状态之前,我们先快速回顾上节课的核心结论:
-
进程 = 内核数据结构(PCB)+ 代码和数据
操作系统为了管理进程,必须先描述,再组织------为每个进程创建 PCB(Process Control Block),再用链表等数据结构将所有 PCB 组织起来。
-
Linux 中的 PCB 具体称为
task_struct,定义在<linux/sched.h>中,包含进程的所有属性(PID、状态、优先级、内存指针等)。 -
创建进程使用
fork()系统调用,父子进程代码共享,数据写时拷贝(Copy‑on‑Write),保证了进程的独立性。
二、进程状态的本质:一个整数
进程状态本质上就是
task_struct结构体内的一个整型变量。
-
操作系统内核可以用
#define定义宏(如#define RUNNING 0,#define SLEEP 1),然后把这个整数赋给进程的state字段。 -
调度器或其他内核模块只需要读取/修改这个整数,就能决定进程该被调度、阻塞还是唤醒。
-
因此,理解进程状态,就是理解这个整数在不同系统下的不同取值及含义。
三、课本上的状态模型:运行、阻塞与挂起
在学习具体的 Linux 状态之前,我们先从操作系统理论的角度理解三个通用概念。

3.1 运行(Running)
-
定义 :一个进程要么正在被 CPU 执行,要么已经准备好随时可以被调度,这两种情况统称为运行状态。
-
在 Linux 中,只要进程的 PCB 位于 CPU 的运行队列(runqueue) 中,就视为
R状态(可运行)。
3.2 阻塞(Blocked)
-
产生原因 :当进程需要等待某个资源(例如键盘输入、磁盘 I/O)时,它无法继续执行。
-
内核如何实现阻塞:
-
操作系统为每一种硬件设备(键盘、磁盘等)都维护了一个设备队列(实际上是一个等待队列)。
-
当进程(比如执行
scanf())发现键盘没有数据时,操作系统会把该进程的 PCB 从运行队列中移除 ,然后链入到键盘设备的等待队列中。 -
从此,CPU 调度器再也看不到这个进程,直到设备就绪。
-
-
唤醒:当键盘上有按键按下,操作系统(作为硬件管理者)会检查该设备的等待队列,把队列中的进程 PCB 重新放回运行队列。之后调度器就会让该进程继续执行。
类比:你把简历交给了面试官(运行队列),面试官没时间看你,就把你的简历放到了"待定人才池"(设备等待队列)。等面试官有空了(设备就绪),再把简历拿回来。
3.3 挂起(Suspended)
-
产生原因 :当系统内存资源严重不足 时,操作系统会把一些短期内不会被调度 的进程的代码和数据 换出到磁盘的 swap 交换分区,只保留 PCB 在内存中。这样可以释放物理内存给更需要它的进程。
-
恢复:当设备就绪(或该进程需要被调度)时,操作系统再把代码和数据从 swap 分区换入内存,重新建立映射,然后把 PCB 重新放回运行队列。
-
对程序员透明 :挂起状态完全由内核自动管理,用户使用
ps命令看不到专门的"挂起"标记。

四、Linux 内核中的具体进程状态
Linux 内核将进程状态定义为一个字符串数组(task_state_array),每一种状态对应一个字符和整数值(下标)。我们可以通过 ps 命令查看进程状态(STAT 列)。
static const char * const task_state_array[] = { "R (running)", /* 0 */ "S (sleeping)", /* 1 */ "D (disk sleep)", /* 2 */ "T (stopped)", /* 4 */ "t (tracing stop)", /* 8 */ "X (dead)", /* 16 */ "Z (zombie)", /* 32 */ };
4.1 R 状态(可运行)
-
含义:进程处于运行队列中,随时可以被调度(不一定正在占用 CPU)。
-
验证 :编写一个死循环(没有 I/O 操作)的程序,用
ps aux查看,大概率看到R状态。 -
注意 :如果程序中有
printf等输出,大部分时间其实在S状态(等待 I/O),只有极少数时间在R状态。


4.2 S 状态(可中断睡眠)
-
含义 :进程处于阻塞状态,等待某个事件(如键盘输入、网络数据)。可以被信号中断 (例如
kill命令可以杀掉它)。 -
也称 :浅度睡眠 /
TASK_INTERRUPTIBLE。 -
验证 :在程序中调用
scanf()或sleep(),进程会进入S状态。


由于printf运行太快了查不到r所以为s状态。s左上角有个加号表示进程在前台运行,./myprocess &放到后台运行

4.3 D 状态(不可中断睡眠)
-
含义 :进程等待磁盘 I/O 等关键操作完成,不能被任何信号中断 (即使
kill -9也无法杀死)。 -
为什么需要 D 状态?
假设一个进程正在向磁盘写入重要数据(例如银行转账记录)。如果此时操作系统允许该进程被杀掉,磁盘可能写入到一半就失败了,而且无法通知用户。D 状态保证进程必须等待 I/O 完成或失败后才能退出,防止数据丢失。
-
实际场景 :对磁盘进行高负载 I/O(如
dd命令),可能短暂出现D状态。如果系统长期存在D状态进程,说明磁盘可能已损坏或 I/O 子系统异常。
4.4 T 状态(暂停 / 停止)
-
含义 :进程被用户或系统主动暂停(不是等待资源)。
-
进入方式:
-
按下
Ctrl+Z将前台进程放入后台并暂停 → 状态显示为T(大写)。 -
向进程发送
SIGSTOP信号(kill -19 PID)也会使其进入T状态。
-
-
恢复 :发送
SIGCONT信号(kill -18 PID)可继续运行。

4.5 t 状态(追踪停止)
-
含义 :进程被调试器(如
gdb)跟踪时,遇到断点而暂停。 -
表现 :使用
gdb调试并设置断点,程序停在断点处时,状态显示为t(小写)。

4.6 X 状态(死亡)
- 含义 :进程已经终止,且已被父进程回收,PCB 即将被释放。此状态在进程列表中不可见。
4.7 Z 状态(僵尸)
-
产生条件 :子进程先于父进程退出,且父进程没有调用
wait()/waitpid()回收子进程的退出状态信息。 -
此时子进程的代码和数据已经释放,但它的 PCB(
task_struct)仍然保留,状态为Z。 -
为什么要保留 PCB?
父进程需要知道子进程是正常退出还是异常退出(例如从
main返回的退出码,或者被哪个信号杀死)。这些信息保存在子进程的 PCB 中,父进程通过系统调用读取后才能完全释放该 PCB。 -
危害 :如果父进程长期不回收,僵尸进程会持续占用内核内存(每个 PCB 约 1~2 KB),导致内存泄漏,严重时系统无法创建新进程。
-
如何避免 :父进程必须使用
wait()或waitpid()回收子进程;或者让子进程被 init 进程收养(孤儿进程)。
五、僵尸进程实验
5.1 模拟僵尸进程
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
int count = 5;
while(count)
{
printf("我是子进程,我正在运行: %d\n", count);
sleep(1);
count--;
}
}
else
{
while(1)
{
printf("我是父进程,我正在运行...\n");
sleep(1);
}
}
5.2 查看僵尸状态
编译运行后,在另一个终端使用 ps aux | grep myproc 查看:
USER PID %CPU %MEM STAT TIME COMMAND whb 1234 0.0 0.0 R+ 0:00 ./myproc # 父进程 whb 1235 0.0 0.0 Z+ 0:00 [myproc] <defunct> # 僵尸子进程
Z表示僵尸状态,<defunct>表示进程已失效但未被回收。
六、孤儿进程(Orphan)
-
定义 :父进程先于子进程退出,子进程就称为孤儿进程。
-
处理方式 :孤儿进程会被系统的 1 号进程(init / systemd) 收养,成为其子进程。1 号进程会负责在它们退出时回收资源,因此不会产生僵尸。
-
验证 :让父进程
sleep(3)后退出,子进程无限循环;观察子进程的 PPID 会变为 1。
为什么要收养?
如果不收养,子进程退出后就没有父进程来回收,会造成内存泄漏。操作系统必须成为"孤儿院",由 1 号进程承担回收责任。
七、内存泄漏的补充讨论
7.1 普通进程的内存泄漏
- 如果程序中用
malloc分配了堆内存但忘记free,进程退出时,操作系统会自动回收该进程所持有的所有内存(包括泄漏的堆内存)。因此,短暂运行的程序中的内存泄漏影响不大。
7.2 常驻进程的内存泄漏
-
常驻进程(如 Web 服务器、数据库、操作系统本身)一旦启动,通常不会退出。如果它们存在内存泄漏,会持续消耗内存,最终导致系统资源耗尽。
-
僵尸进程就是一种典型的常驻内存泄漏------PCB 不被回收,持续占用内核内存。
八、Linux 内核链表设计:一个 PCB 可属于多个队列
8.1 传统链表 vs Linux 内核链表
-
传统链表:节点内部包含
next/prev指针和数据。一个节点只能属于一个链表。 -
Linux 内核链表:将
next/prev指针单独封装成struct list_head,然后将该结构体作为成员 嵌入到需要链接的数据结构中(如task_struct中的tasks和run_list)。
struct list_head { struct list_head *next, *prev; }; struct task_struct { // ... 大量进程属性 ... struct list_head tasks; // 用于全局进程链表 struct list_head run_list; // 用于运行队列 // ... 其他链表节点 ... };
8.2 优点

-
一个
task_struct对象可以同时属于多个不同的链表 (例如全局进程链表、运行队列、等待队列等),只需在结构体中包含多个list_head成员。 -
链表操作(增删改查)与具体数据解耦,实现代码复用。
8.3 如何从链表节点找到包含它的结构体?
内核提供了 container_of 宏,通过成员地址反推整个结构体的起始地址:
#define container_of(ptr, type, member) ({ \ const typeof(((type *)0)->member) *__mptr = (ptr); \ (type *)((char *)__mptr - offsetof(type, member)); \ })
- 遍历全局进程链表时,可以通过
tasks节点的地址计算出对应的task_struct地址,从而访问所有属性。
8.4 运行队列与调度
-
每个 CPU 有一个运行队列(
runqueue),其中包含多个优先级数组(prio_array),每个优先级对应一个链表。 -
进程的
run_list节点被链入对应优先级的队列中。 -
调度器通过位图快速找到非空最高优先级队列,实现 O(1) 调度。
九、本节课总结
| 状态 | 含义 | 可被杀死? | 典型场景 |
|---|---|---|---|
R |
可运行(在运行队列中) | 是 | 死循环计算 |
S |
可中断睡眠(阻塞) | 是 | scanf()、sleep() |
D |
不可中断睡眠 | 否 | 磁盘 I/O 等待 |
T |
暂停(Ctrl+Z) |
是(需 SIGCONT 恢复) |
作业控制 |
t |
追踪暂停(调试) | 是 | gdb 断点 |
Z |
僵尸 | 否(只能父进程回收) | 子进程退出父进程未回收 |
X |
死亡(不可见) | --- | 已完全释放 |
核心概念回顾
-
运行、阻塞、挂起:通过 PCB 在不同队列(运行队列、设备等待队列)之间的移动来实现状态切换;挂起时将代码和数据换出到 swap 分区。
-
僵尸进程 :子进程已退出,父进程未回收,PCB 残留,造成内存泄漏。必须由父进程调用
wait()回收。 -
孤儿进程:父进程先退出,子进程被 init 收养,自动回收,不会成为僵尸。
-
内核链表 :
list_head的设计使得一个 PCB 可以同时属于多个数据结构,实现了灵活的组织方式。
下节课将介绍进程优先级、调度算法以及进程切换的详细过程。

