前言
操作系统会通过 PCB(进程控制块)管理进程,也知道了进程创建出来之后,需要由 CPU 调度执行。
但是,这里又有一个问题:
一个进程创建出来之后,是不是就一直在运行呢?
当然不是。
比如,我们打开一个程序,程序正在执行,这时候突然需要从键盘读取数据,但是我们还没有输入。程序总不能一直占着 CPU 等我们吧?
再比如,程序需要读取硬盘中的数据,而硬盘的速度又比较慢,那么在等待数据的这段时间里,进程又应该处于什么状态呢?
所以,操作系统需要对进程进行状态管理,根据进程当前的情况,决定它什么时候运行、什么时候等待、什么时候暂停。
今天我们就来学习 Linux 中的进程状态,并进一步理解:进程状态发生变化时,操作系统到底是怎么管理这些进程的?
一、操作系统的“进程状态”
在正式学习 Linux 之前,我们先看一下操作系统理论中的进程状态。
一个进程从创建到结束,并不是一直处于同一种状态,而是会经历不同的阶段。
通常可以将进程的基本状态分为:
- 新建状态(New):进程正在被创建。
- 就绪状态(Ready):进程已经准备好运行,只需要等待 CPU 调度。
- 运行状态(Running):进程正在 CPU 上执行。
- 阻塞状态(Blocked):进程因为等待某个事件,暂时不能继续执行。
- 终止状态(Terminated):进程已经执行结束。
我们可以把它想象成一个排队办事的过程。
一个人刚来到办事大厅,相当于进程被创建;准备好材料,排队等待工作人员办理,相当于就绪;轮到自己办理,相当于运行;办理过程中发现还缺少某份材料,需要等待,相当于阻塞;最后办理结束,相当于终止。
这里最容易混淆的是就绪状态和阻塞状态。
就绪状态:我已经准备好了,只是还没有轮到我使用 CPU。
阻塞状态:我现在还不能继续执行,因为我正在等待某个条件满足。
比如:
进程 A:已经准备好了,等待 CPU -> 就绪状态 进程 B:正在等待磁盘读取数据 -> 阻塞状态它们虽然都没有占用 CPU,但是原因完全不同。
一个是没有得到 CPU 的使用机会,另一个是当前任务还不能继续执行。
那么,进程在这些状态之间是怎么切换的呢?
这就是操作系统理论中比较基础的进程状态模型。
但是,我们今天学习的是 Linux,所以接下来还需要看看 Linux 是怎么表示这些状态的。
二、Linux中的进程状态
2.1 进程的状态有哪些
在 Linux 中,一个进程也有不同的状态。
我们可以查看 Linux 内核中对进程状态的定义(不同内核版本的具体定义可能有所调整)。
常见的状态包括:
| 状态 | 英文 | 含义 |
|---|---|---|
| R | Running / Runnable | 运行或可运行 |
| S | Interruptible Sleep | 可中断睡眠 |
| D | Uninterruptible Sleep | 不可中断睡眠 |
| T | Stopped | 停止 |
| t | Tracing Stop | 追踪停止 |
| Z | Zombie | 僵尸状态 |
| X | Dead | 死亡状态 |
先不要急着背这些英文,我们一个一个理解。
这里有一个特别容易误解的地方:
Linux 中的 R 状态,不代表进程一定正在 CPU 上运行。
它还包括已经准备好、正在等待 CPU 调度的进程。
为什么呢?
因为 CPU 的数量是有限的,而系统中的进程可能有很多。
假设现在有三个进程:
进程 A:正在 CPU 上执行 进程 B:已经准备好,等待 CPU 进程 C:已经准备好,等待 CPU虽然只有 A 真正占用了 CPU,但是 B 和 C 也已经具备运行条件。
所以在 Linux 中,它们都可以属于 R 状态。
也就是说:
- 正在 CPU 上执行:R。
- 已经准备好,正在运行队列中等待 CPU:R。
这也是为什么 Linux 的 R 状态不能简单地理解为操作系统理论中的运行状态,它实际上包含了运行和就绪两种情况。
2.2 调度队列FIFO算法 && 进程运行状态
既然有些进程已经准备好运行,只是在等待 CPU,那么操作系统怎么知道应该让谁先运行呢?
这就涉及进程调度。
我们先回忆一下之前学过的 PCB。
在 Linux 中,进程控制块主要通过task_struct结构体来描述。
每个进程都有自己的task_struct,里面保存着进程的 PID、状态、优先级、内存管理信息等。
操作系统需要管理大量的进程,因此会使用各种数据结构组织这些进程。
其中,我们可以先从最简单的队列开始理解。
2.2.1 什么是调度队列?
假设现在有三个进程,它们都已经准备好运行:
进程 A 进程 B 进程 C但是 CPU 当前只能执行其中一个进程。
那么,操作系统就需要把这些等待 CPU 的进程组织起来,方便调度器选择。
我们可以把它理解成一个排队的过程:
CPU ^ | +---------+ | 进程 A | +---------+ ^ | +---------+ | 进程 B | +---------+ ^ | +---------+ | 进程 C | +---------+这就是运行队列(Run Queue)的基本思想。
运行队列中保存的是已经具备运行条件、等待调度的任务。
当 CPU 空闲或者需要进行进程切换时,调度器就会从合适的任务中选择一个,让它获得 CPU 的使用权。
这里要注意:
运行队列不等于系统中的全部进程链表。
全局进程链表主要用于组织和遍历系统中的进程,而运行队列主要服务于 CPU 调度。
两者的用途是不一样的。
2.2.2 FIFO 算法是什么?
FIFO 是 First In First Out 的缩写,意思就是先进先出。
例如:
先进入队列:A 第二个进入:B 第三个进入:C那么按照最简单的 FIFO 规则,处理顺序就是:
A -> B -> C这和我们平时排队买东西是一个道理,先来的人先处理。
不过,这里只是为了帮助我们理解队列的组织方式。
Linux 的实际进程调度并不是简单地使用一个 FIFO 队列,让所有进程严格按照进入顺序执行。
Linux 调度器还会考虑任务的调度策略、优先级、运行时间等因素。
因此,不要把 FIFO 理解成 Linux 调度器的全部工作原理。
我们先记住:
运行队列的作用是组织等待 CPU 的可运行任务,而调度器负责从中选择任务执行。
2.3 设备树 && 进程的阻塞状态
前面我们说过,进程有时候会因为等待某个事件而进入阻塞状态。
那么,进程为什么会阻塞?
我们先从计算机中的硬件设备说起。
2.3.1 什么是设备树?
我们平时使用电脑,会接触到很多硬件设备:
- 键盘
- 鼠标
- 显示器
- 硬盘
- 网卡
- 摄像头
这些设备都需要操作系统进行管理。
操作系统并不是直接通过普通应用程序去操作每一个硬件,而是通常通过设备驱动程序与硬件进行交互。
那么,操作系统怎么知道计算机里面有哪些设备?这些设备又有什么属性?
这里就会接触到设备树(Device Tree)。
设备树可以理解成一份描述硬件信息的结构化数据。
比如:
计算机硬件信息 | v 设备树 | +------ CPU | +------ 内存 | +------ 磁盘 | +------ 网卡 | +------ 其他设备它主要用于描述硬件设备的组成、地址、中断等信息,让操作系统能够了解硬件平台的情况。
尤其是在很多嵌入式 Linux 系统中,设备树是非常重要的硬件描述机制。
但是,这里必须区分一个概念:
设备树不等于等待队列。
设备树主要描述硬件信息,而等待队列是操作系统在运行过程中,用来管理等待某个事件的进程或线程的数据结构。
它们不是同一个东西,也不是设备树上的每个节点都直接对应一个等待队列。
我们接下来真正要学习的,是等待队列。
2.3.2 什么是阻塞状态?
假设我们现在正在使用键盘输入内容。
程序需要从键盘读取数据:
scanf("%d",&num);但是,如果我们还没有输入数据,程序怎么办?
它不能凭空得到我们想输入的内容。
于是,程序就需要等待键盘输入。
这时候,如果程序一直占用 CPU 等待用户输入,就会造成资源浪费。
所以,操作系统会让当前进程进入等待状态,暂时不参与 CPU 调度。
这就是阻塞状态。
我们可以把这个过程理解成:
进程正在运行 | v 执行 scanf() | v 发现还没有输入数据 | v 需要等待键盘输入 | v 进入等待队列 | v 进程进入睡眠状态 | v 键盘输入数据 | v 等待条件满足 | v 进程被唤醒 | v 重新进入可运行状态那么,等待队列又是什么?
等待队列就是操作系统用来组织等待某个事件的任务的一种数据结构。
例如:
- 进程 A 等待键盘输入。
- 进程 B 等待磁盘 I/O 完成。
- 进程 C 等待网卡接收到数据。
它们等待的事情不同,因此可以被组织在不同的等待队列中。
当对应的事件发生时,内核就可以唤醒等待的进程,让它重新具备运行条件。
这里要特别注意:
进程被唤醒,并不意味着它马上就能使用 CPU。
它只是从等待状态变成可运行状态,之后还需要等待调度器分配 CPU。
唤醒 ≠ 立即运行。
2.4 交换分区与进程挂起
前面我们讲了,进程在等待某个事件时会进入阻塞状态。
那么,如果系统中的进程越来越多,物理内存不够用了怎么办?
这就涉及内存管理中的交换机制。
2.4.1 交换分区的定义
我们之前学习虚拟内存时,知道了每个进程都有自己的虚拟地址空间。
但是,虚拟内存最终还是需要通过页表映射到物理内存。
而物理内存的容量是有限的。
假设现在系统中有很多进程,其中一些进程正在运行,另一些进程可能已经阻塞了很长时间。
这些阻塞进程暂时不需要 CPU,如果它们占用了大量物理内存,就可能增加系统的内存压力。
那么,操作系统能不能把暂时不常用的一部分内存数据放到磁盘上呢?
可以。
这就涉及交换空间(Swap)。
交换空间是磁盘上用于辅助内存管理的一块空间,可以用于保存被换出的内存页面。
它可以是专门的交换分区,也可以是交换文件。
例如:
物理内存 | +------+------+ | | 常用页面 暂时不用的页面 | | v v 保留内存 换出 | v 交换空间 (磁盘)这样,操作系统就可以在一定程度上释放物理内存,给其他任务使用。
注意:
交换空间存放的不是简单意义上的整个程序代码,而是内存管理过程中被换出的页面。
而且,交换机制和进程挂起并不是完全相同的概念。
2.4.2 挂起与优先挂起
那么,什么是挂起?
我们可以先从操作系统理论中的挂起状态理解。
进程原本可能处于阻塞状态,因为等待某个事件而无法继续执行。
如果系统内存压力比较大,操作系统就可能把这个进程的一部分内存页面换出到磁盘。
在传统操作系统模型中,如果进程被暂时移出内存,并且暂时不参与调度,就可以进一步理解为挂起状态。
例如:
进程 A | v 阻塞等待 | v 内存资源紧张 | v 暂时挂起 | v 部分内存页面换出 | v 交换空间为什么阻塞进程可能会优先成为换出对象?
因为它正在等待某个事件,短时间内可能不会使用 CPU。
相比正在积极运行的进程,它可能有更多暂时不用的内存页面。
不过,这并不意味着操作系统一定会优先换出所有阻塞进程。
现代 Linux 的内存回收机制会综合考虑页面的使用情况、访问频率、内存压力等因素。
另外,进程被换出部分内存页面,不代表 PCB 也被一起搬到磁盘上。
PCB 仍然需要由内核维护,操作系统必须知道这个进程的存在以及它当前的状态。
这里我们要分清三个概念:
- 阻塞:进程正在等待某个事件。
- 换出:某些内存页面暂时从物理内存转移到交换空间。
- 挂起:操作系统理论中的一种进程状态,表示进程被暂时停止执行。
它们之间有关联,但不能直接画等号。
2.5 进程的状态变化
前面我们已经把各种状态分别介绍了一遍。
现在把它们串起来,就能理解一个进程在整个运行过程中会发生什么。
例如:
一个程序刚刚创建出来,先进入就绪状态,等待 CPU 调度。
获得 CPU 后,进入运行状态。
如果程序需要读取磁盘数据,就会进入阻塞状态,等待 I/O 完成。
等到数据读取完成,进程被唤醒,重新进入就绪状态。
之后再次获得 CPU,继续执行。
最后,程序执行完毕,进入终止状态。
可以概括为:
创建 | v 就绪 | CPU调度 | v 运行 / | \ / | \ v v v 阻塞 就绪 终止 | v 等待事件 | v 被唤醒 | +------> 就绪这里再补充一个容易忽略的地方:
进程从运行状态变成就绪状态,不一定是因为它主动等待了什么。
也可能是因为它的 CPU 时间片用完了,或者调度器决定让其他任务运行。
所以,进程状态的变化既可能由进程自身执行的操作引起,也可能由操作系统的调度和事件处理机制引起。
到这里,我们已经知道了进程为什么会在不同状态之间切换。
但是,还有一个问题:
操作系统到底是怎么把进程从一个队列转移到另一个队列的?
这就需要继续理解 Linux 内核链表。
三、理解内核链表的相关话题
3.1 内核链表不是传统链表
之前我们学习 C++ 数据结构时,接触过链表。
例如,最普通的双向链表:
structNode{intdata;Node*prev;Node*next;};每个节点中都保存着数据,以及指向前后节点的指针。
通过这些指针,我们就可以把多个节点连接起来。
但是,Linux 内核中的链表设计和我们平时写的传统链表有所不同。
Linux 内核使用了一种非常经典的设计:把链表节点从实际数据中独立出来。
先看一下代码:
structlist_head{structlist_head*next;structlist_head*prev;};structtask_struct{intpid;intstate;structlist_headtasks;// 其他进程信息};这里的list_head就是 Linux 内核链表的核心结构。
它里面只有两个指针:
next:指向下一个链表节点。prev:指向上一个链表节点。
而真正的进程信息,还是保存在task_struct中。
也就是说,list_head本身不保存 PID、状态等进程信息,它只负责建立链表之间的连接。
我们可以这样理解:
task_struct +----------------+ | pid | | state | | | | list_head | | next | | prev | | | | 其他信息 | +----------------+这就像给一个完整的 PCB 单独安装了一个连接接口。
链表只需要操作这个接口,就可以把不同的 PCB 组织起来。
这样设计的好处是什么?
我们接着看。
3.2 为什么内核链表要这么设计
3.2.1 更方便的偏移量计算——offset 宏
既然list_head只是一个独立的连接接口,那么问题来了:
当我们遍历链表时,拿到的是list_head的地址。
可是,我们真正想操作的是整个task_struct。
那么,怎么从一个成员变量的地址,找到它所在结构体的起始地址呢?
这就涉及结构体成员的偏移量。
例如:
structtask_struct{intpid;intstate;structlist_headtasks;};假设:
结构体起始地址:1000 pid :1000 state :1004 tasks :1008那么,tasks相对于结构体起始位置的偏移量就是:
1008 - 1000 = 8如果我们已经知道tasks的地址,就可以通过减去偏移量,得到整个结构体的起始地址。
公式就是:
结构体首地址 = 成员地址 - 成员偏移量Linux 内核中有一个非常重要的宏:
offsetof(type,member)它可以帮助我们获取某个成员在结构体中的偏移量。
例如:
#include<stddef.h>size_toffset=offsetof(structtask_struct,tasks);这样就能得到tasks成员相对于结构体起始地址的偏移量。
那么,Linux 内核如何通过链表节点找到整个结构体呢?
它使用了一个非常经典的宏:
container_of(ptr,type,member)它的作用就是:
已知结构体中某个成员的地址,反过来找到整个结构体的地址。
例如:
structtask_struct*task=container_of(pos,structtask_struct,tasks);其中:
pos:指向tasks成员的指针。struct task_struct:成员所在的结构体类型。tasks:结构体中的成员名称。
这样,就可以通过链表节点找到整个 PCB。
这就是 Linux 内核链表非常巧妙的地方。
3.2.2 彻底理清“流动”的本质:多链表动态解绑与重组
现在我们已经知道了:
Linux 内核链表并不是直接把 PCB 当作链表节点,而是在 PCB 内部嵌入list_head,通过它来建立连接。
那么,为什么要这么设计?
我们回到进程状态变化这个问题。
假设现在有一个进程 A。
它正在运行,后来因为需要读取磁盘数据而进入阻塞状态。
那么,操作系统要怎么管理它?
我们先想一个问题:
一个进程是不是只需要被组织在一条链表中?
显然不是。
因为操作系统需要从不同角度管理进程。
例如:
- 想知道系统中有哪些进程,需要全局进程链表。
- 想知道哪些进程可以被 CPU 调度,需要调度相关的数据结构。
- 想知道哪些进程正在等待某个事件,需要等待队列。
所以,同一个进程可能需要参与不同的数据组织关系。
3.2.2.1 物理上:它们住在一起
我们先从物理内存角度理解。
一个进程对应着内核中的进程描述结构task_struct。
我们可以把它想象成一个很大的盒子,里面保存着进程的各种信息。
而 Linux 内核链表的节点可以嵌入在这个盒子里面。
例如:
structtask_struct{intpid;intstate;structlist_headtasks;structlist_headexample_wait_list;// 其他信息};这里为了方便理解,假设我们给结构体中放入了两个不同的链表节点。
那么:
task_struct +--------------------+ | pid | | state | | | | tasks | | next / prev | | | | example_wait_list | | next / prev | | | | 其他进程信息 | +--------------------+注意:
这些list_head都属于同一个task_struct。
它们只是不同的连接接口。
所以,从物理上看,它们都住在同一个 PCB 里面。
3.2.2.2 逻辑上:它们属于完全不同的链表
虽然这些连接接口都在同一个 PCB 里面,但它们可以分别加入不同的链表。
例如:
全局进程链表: A <-> B <-> C <-> D 某个等待队列: B <-> D 其他调度相关结构: A <-> C这里要注意:
上面的 A、B、C、D 代表同一个进程实体,只是为了说明它们可以通过不同的链表成员参与不同的组织关系。
它们并没有因为加入不同链表,就变成多个进程。
我们可以把它想象成一个人同时拥有多个身份:
- 在学校,他属于班级。
- 在社团,他属于社团成员。
- 在图书馆,他又属于借阅者。
人还是同一个人,只是不同的组织关系不同。
PCB 也是一样。
它还是原来的那个 PCB,只不过可以通过不同的链表节点,参与不同的管理结构。
这里还需要特别纠正一个容易出现的误区:
不是说 Linux 中所有进程状态切换,都一定是从一条普通链表删除,再插入另一条普通链表。
Linux 的全局进程链表、调度器运行队列、等待队列,具体实现并不完全相同。
尤其是调度器的运行队列,还涉及调度类、优先级和其他调度数据结构。
我们这里讲的是内核链表的设计思想,而不是说 Linux 所有调度操作都只有list_del()和list_add()两步。
3.2.2.3 “流动”是如何发生的?
现在我们回到进程状态变化。
假设进程 A 正在运行。
它执行到某个位置,需要等待磁盘数据。
那么,内核就需要让它暂时停止参与 CPU 调度,并等待对应的 I/O 事件。
从逻辑上看:
进程 A | v 正在运行 | v 等待磁盘 | v 进入睡眠 | v 等待事件完成从数据结构管理的角度看,内核需要:
- 将任务设置为相应的睡眠状态。
- 将它从当前不再适合参与调度的运行状态中移开。
- 根据等待机制,将它加入对应的等待队列。
- 等待事件发生。
- 条件满足后,将它唤醒,使其重新具备运行条件。
可以把这个过程理解为:
运行队列 | | 进程 A 等待 I/O v 等待队列 | | I/O 完成 v 可运行状态 | v 等待 CPU 调度这里的重点不是 PCB 被搬来搬去,而是:
进程实体仍然是原来的那个,变化的是它的状态以及它参与的数据结构关系。
也就是说,操作系统不需要重新创建一个 PCB,也不需要复制整个 PCB。
它只需要根据当前的运行情况,更新相应的状态和管理关系。
这就是我们所说的进程在不同队列之间的动态组织。
3.2.2.4 核心总结
这一部分我们主要理解了三个问题:
第一,PCB 是一个实体。
进程的核心信息由task_struct保存,不会因为进程状态变化就重新创建一个 PCB。
第二,内核链表节点和 PCB 是分开的。
Linux 使用list_head作为连接接口,将链表结构与实际数据解耦。
第三,不同链表可以组织同一个 PCB。
通过不同的链表成员,内核可以从不同角度组织和管理进程。
所以,当进程状态发生变化时,操作系统需要更新进程状态,并根据具体机制调整相关的数据结构关系。
这就是 Linux 内核链表设计的巧妙之处。
四、进程状态的查看
前面我们一直在通过图示理解进程状态。
那么,在实际使用 Linux 时,我们怎么知道一个进程当前处于什么状态呢?
这就需要使用ps命令。
4.1 回顾 Linux 的进程状态类型
我们先看一下 Linux 中常见的状态:
| 标识 | 状态 | 说明 |
|---|---|---|
| R | Running | 正在运行或等待 CPU |
| S | Sleeping | 可中断睡眠 |
| D | Disk Sleep | 不可中断睡眠 |
| T | Stopped | 停止 |
| t | Tracing Stop | 追踪停止 |
| Z | Zombie | 僵尸状态 |
| X | Dead | 死亡状态 |
这些状态会显示在ps命令的STAT列中。
例如:
psaux也可以使用:
ps-ef不过,想直接查看状态标识,ps aux的输出中有一个STAT列,非常方便。
例如:
USER PID %CPU %MEM STAT COMMAND root 1234 0.0 0.1 S ./myprocess root 1235 90.0 0.1 R ./myprocess这里:
PID:进程编号。%CPU:CPU 使用率。%MEM:内存使用率。STAT:进程状态。COMMAND:进程对应的命令。
注意,STAT后面有时还会出现其他附加标识,例如+,表示该进程属于前台进程组。
4.2 运行 R 与睡眠 S 状态
我们先通过代码来观察 R 和 S 状态。
4.2.1 观察 S 状态
先写一个简单的程序:
#include<iostream>#include<unistd.h>intmain(){while(true){std::cout<<"hello Linux"<<std::endl;sleep(1);}return0;}这里程序每打印一次内容,就会调用:
sleep(1);让当前进程休眠一秒。
在这段时间里,程序并不需要一直占用 CPU。
因此,我们可以在另一个终端中查看:
psaux|grepmyprocess就可能看到:
STAT: S为什么会出现 S?
因为进程执行到sleep(1)时,需要等待一秒钟。
它暂时不具备继续执行的条件,所以进入可中断睡眠状态。
这里再补充一下:
S 状态不仅仅是sleep()引起的。
像等待键盘输入、等待某些事件等,也可能让进程进入 S 状态。
4.2.2 观察 R 状态
那么,如果我们不让进程进行 I/O,也不让它休眠,只让它一直计算,会怎么样?
可以写成:
#include<iostream>#include<unistd.h>intmain(){std::cout<<"PID: "<<getpid()<<std::endl;while(true){// 一直进行计算}return0;}这个程序进入死循环后,会一直尝试执行 CPU 指令。
因此,它通常会显示为 R 状态。
我们再使用:
psaux|grepmyprocess查看。
可以发现,进程状态通常是:
R这说明它处于运行或可运行状态。
不过,这里有一个小细节:
如果进程正在 CPU 上执行,它是 R;如果它已经准备好,但是暂时在等待 CPU 调度,它同样也是 R。
所以,R 不等于一定正在占用 CPU。
4.2.3 前台进程与后台进程
我们在终端中运行程序时,还会接触到前台进程和后台进程。
例如:
./myprocess这样启动的程序默认在前台运行。
如果我们想让它在后台运行,可以在命令后面加上&:
./myprocess&这样 Shell 就可以继续接受其他命令。
我们还可以通过:
jobs查看当前 Shell 管理的后台任务。
另外,使用ps aux时,如果进程属于前台进程组,STAT列后面可能会出现+。
这里要区分:
前台、后台描述的是进程与终端作业控制之间的关系,而 R、S、D 等描述的是进程状态。
它们不是同一个概念。
4.3 停止 T 与追踪停止状态 t
除了运行和睡眠,进程还可能被暂停。
Linux 中常见的停止状态有:
- T:停止状态。
- t:追踪停止状态。
4.3.1 T 停止状态
我们先启动一个程序:
./myprocess然后按下:
Ctrl + Z程序就会暂停。
这时再查看进程状态,就可能看到:
T为什么会这样?
因为终端在处理Ctrl + Z时,通常会向前台进程组发送SIGTSTP信号。
进程接收到这个信号后,就会进入停止状态。
那么,如何让它继续执行呢?
可以使用:
fg把任务重新调回前台。
也可以通过:
bg让它在后台继续运行。
这里再补充一个信号:
kill-STOPPID可以向指定进程发送SIGSTOP,强制让它停止。
与SIGTSTP不同,SIGSTOP不能被程序捕获或忽略。
而恢复运行可以使用:
kill-CONTPID发送继续执行的信号。
4.3.2 t 追踪停止状态
那么,小写的 t 又是什么?
它通常出现在进程被调试器追踪的场景中。
例如,我们使用 GDB 调试程序时:
gdb ./myprocess调试器可以控制程序的执行过程。
例如:
- 设置断点。
- 单步执行。
- 查看变量。
- 控制程序继续运行。
当被调试的进程暂停下来,等待调试器进一步控制时,就可能处于追踪停止状态。
所以:
- T:一般的停止状态。
- t:与调试追踪有关的停止状态。
两者都表示进程暂时停止执行,但产生原因不同。
4.4 深度睡眠 D
最后,我们再看一个比较特殊的状态:D 状态。
D 状态也叫不可中断睡眠状态。
4.4.1 对比一般睡眠状态
我们之前讲过 S 状态。
例如:
sleep(10);程序执行后,会进入睡眠状态,等待十秒。
这时候,如果我们向进程发送终止信号,进程通常可以响应信号并结束。
但是 D 状态就不一样了。
D 状态通常表示进程正在等待某些不能被普通信号打断的内核操作完成,例如某些 I/O 操作。
例如:
进程正在等待磁盘 I/O | v 进入 D | v 等待 I/O 完成 | v 操作完成后继续这时候,即使我们向进程发送SIGKILL,它也可能暂时无法立即退出。
为什么?
因为进程当前正在等待内核中某个不可中断的操作完成。
信号可以处于待处理状态,但进程需要等到相应的等待过程结束,才有机会处理终止请求。
所以,不要简单地认为 D 状态的进程永远杀不掉。
更准确地说:
D 状态下,进程通常不能立即响应普通信号,必须等待不可中断的等待过程结束。
4.4.2 深度睡眠的意义
为什么 Linux 需要 D 状态?
我们可以从 I/O 操作理解。
假设一个进程正在进行磁盘 I/O。
磁盘的处理速度相对于 CPU 来说比较慢,所以进程可能需要等待一段时间。
如果进程在等待期间可以随意被中断,那么某些正在进行的内核操作可能无法按照预期完成。
因此,对于一些关键的内核等待过程,Linux 会使用不可中断睡眠机制。
这样可以保证相关操作按照内核预期的流程完成。
这里要注意:
D 状态并不是专门为了防止进程被内存管理机制杀掉而设计的。
它的核心目的是让某些关键的等待过程不被普通信号打断。
4.4.3 如何观察 D 状态?
在实际系统中,D 状态通常出现在某些 I/O 等待场景中。
例如:
- 磁盘设备响应缓慢。
- 网络文件系统出现异常。
- 某些驱动程序中的 I/O 操作迟迟没有完成。
我们可以通过:
psaux或者:
top观察进程状态。
如果STAT一栏出现 D,就说明进程处于不可中断睡眠状态。
不过,普通电脑不一定容易稳定复现 D 状态。
因为正常情况下,磁盘 I/O 往往很快就能完成,进程可能还没有来得及被我们观察,就已经恢复运行了。
所以,我们先理解它的产生原因和特点即可。
五、本篇总结
到这里,我们就把 Linux 中的进程状态基本串起来了。
最开始,我们学习了操作系统理论中的五种基本状态,知道了进程并不是一直在运行,而是会根据 CPU 调度、I/O 等待和执行情况发生变化。
接着,我们学习了 Linux 中常见的 R、S、D、T、t、Z、X 等状态,理解了它们分别代表什么。
然后,我们进一步学习了运行队列、等待队列,以及内核链表的组织方式。
最后,通过ps aux和top,我们可以在 Linux 中实际观察进程状态。
这里最重要的几个知识点:
- R 状态包含正在运行和等待 CPU 调度的任务。
- S 状态表示可中断睡眠,D 状态表示不可中断睡眠。
- T 和 t 都属于停止状态,但产生原因不同。
- 等待队列用于组织等待某个事件的任务,设备树不是等待队列。
- Linux 内核链表通过
list_head将链表节点与实际数据结构解耦。 - 进程状态变化不意味着 PCB 被复制或搬动,变化的是进程状态及相关管理关系。
ps aux可以帮助我们查看进程状态,重点关注STAT列。
我们可以用一句话总结今天的内容:
进程状态描述的是进程当前处于什么执行阶段,而队列和链表则帮助操作系统组织、管理这些进程。
进程从运行到阻塞,再从阻塞到就绪,最终重新获得 CPU 执行,这背后离不开操作系统的调度机制和数据结构设计。
下一篇我们继续学习进程等待与进程退出,看看父进程是如何等待子进程的,以及僵尸进程、孤儿进程又是怎么产生的。
我是 Yi,我们下篇博客见!