系统核心解析:深入操作系统内部机制——进程管理与控制指南(二)【进程状态】
♥♥♥~~~~~~欢迎光临知星小度博客空间~~~~~~♥♥♥
♥♥♥零星地变得优秀~也能拼凑出星河~♥♥♥
♥♥♥我们一起努力成为更好的自己~♥♥♥
♥♥♥如果这一篇博客对你有帮助~别忘了点赞分享哦~♥♥♥
♥♥♥如果有什么问题可以评论区留言或者私信我哦~♥♥♥
✨✨✨✨✨✨个人主页✨✨✨✨✨✨
上一篇博客我们对进程有了一定的理解,这一篇博客我们来开始学习进程状态~人有开心的状态,有伤心的状态,那么进程会有什么状态呢?我们一起来看看~准备好了吗~我们发车去探索进程的奥秘啦~🚗🚗🚗🚗🚗🚗
目录
进程状态😜
什么是进程状态?😄
常见的进程状态(传统OS理论)😀
核心三状态模型及其转换😁
扩展状态:挂起(Suspend)—— 应对资源不足😜
传统OS理论更完整的进程状态转换图😊
关键点总结😄
Linux下的进程状态😋
内核如何组织进程 - 双链表管理😍
详细的进程状态🙃
僵尸进程👌
什么是僵尸进程?😊
为什么需要僵尸状态?🙃
僵尸进程的危害👍
孤儿进程🎃
什么是孤儿进程?😐
内核如何处理孤儿进程?🤔
僵尸进程和孤儿进程对比总结😭
进程状态😜
什么是进程状态?😄
进程状态是描述进程当前在其生命周期中所处阶段的一个标志。
在代码中,状态通常用一个整数(如 int status)表示,例如:
#define RUNNING 1
#define READY 2
#define BLOCK 3
// ...
进程状态决定了:
①进程是否可被调度;
②进程当前在哪个队列中等待;
③操作系统如何管理该进程。
队列归属:状态改变的背后,是操作系统将这个进程的PCB从一个队列移动到另一个队列的操作,这是资源管理的核心机制,进程也是在竞争资源(CPU资源、外设资源)的。
运行队列:存放所有准备好、等待CPU的进程。
设备等待队列:存放所有等待特定硬件事件(如键盘输入)的进程。
常见的进程状态(传统OS理论)😀
核心三状态模型及其转换😁
运行状态 (Running):
含义:进程正在CPU上执行指令。在任意时刻(单核CPU情况下),有且只有一个进程处于运行状态。
资源竞争:正在独占并使用CPU资源。
队列:其PCB正被CPU处理,但它同时也是运行队列(run_queue) 的一部分。当进程处于运行状态时,它仍然是运行队列中的一个节点,但它是队列中当前被调度器选中并正在执行的那个特殊成员。
就绪状态 (Ready):
含义:进程已经准备就绪(所需资源除CPU外都已分配到位),正在等待操作系统调度器为其分配CPU时间片。万事俱备,只欠CPU。
资源竞争:竞争CPU资源。
队列:其PCB位于运行队列(run_queue) 中,排队等待被CPU调度。运行队列中除了当前正在运行的哪一个进程外,其他所有进程都处于就绪状态。
运行状态和就绪状态有点难以区分,我们来仔细看看它们的区别:
首先我们使用一个比喻来进行理解,单核CPU在同一时间只能像一个厨师一样,专心“烹饪”(执行)一道“菜”(进程)。厨师(CPU)做完手头这道菜(时间片用完),就会从台子上的菜(运行队列)里选出下一道开始烹饪。被选中的菜就从“就绪”变为“运行”,刚做完的菜如果没做完,就放回台子(运行队列)变回“就绪”。
对比表格
特征 | 运行状态 (Running) | 就绪状态 (Ready) |
---|---|---|
核心定义 | 进程正在CPU上执行指令 | 进程已准备好,可以执行,正在等待获得CPU时间 |
是否占用CPU | 是(在单核CPU上,同一时刻只有一个进程处于此状态) | 否 |
资源需求 | 正在使用CPU资源 | 除CPU外,其他所有资源(如内存、数据)已就绪 |
在队列中的位置 | 其PCB位于运行队列中,且正被CPU处理 | 其PCB位于运行队列中,排队等待被CPU处理 |
状态转换触发 | 时间片用完:被迫让出CPU,从运行态 -> 就绪态 等待事件:主动请求I/O等,从运行态 -> 阻塞态 | 被调度器选中:从就绪态 -> 运行态 |
核心区别一句话总结:是否正在占用CPU~
正在占用CPU的,是运行状态;一切准备就绪、只差CPU资源的,是就绪状态。它们共享同一个队列
阻塞状态 (Blocked / Waiting):
含义:进程因等待某个事件(如等待用户输入、等待磁盘读取数据、等待另一个进程的信号)而无法继续执行。
资源竞争:竞争外部设备(如键盘、硬盘)或其它软件资源。
队列:其PCB不在运行队列中,而是被移到了所等待事件对应的等待队列中。例如,等待键盘输入的进程,其PCB会被放在键盘驱动程序的等待队列里。
扩展状态:挂起(Suspend)—— 应对资源不足😜
当系统资源(尤其是内存)非常紧张时,基础的三状态模型就不够用了,于是引入了“挂起”状态。
为什么需要挂起?
当内存资源严重不足时,操作系统需要将一些暂时不会运行的进程的代码和数据从内存中换出(Swap out) 到磁盘上的交换空间(Swap分区),以释放物理内存。这个过程称为“挂起”。
挂起状态的含义:
被挂起的进程,其代码和数据不再占用物理内存,但其PCB(进程描述符) 仍然保留在内存中,操作系统通过PCB来管理这个被挂起的进程。
挂起状态的分类(与三状态结合):
就绪挂起 (Ready-Suspend):进程原本处于就绪状态,但因内存不足被换出到磁盘。当需要时,它可以被换入内存,直接进入就绪队列。
阻塞挂起 (Blocked-Suspend):进程原本处于阻塞状态,并且被换出到磁盘。即使它等待的事件发生了,它也必须先被换入内存,才能变回阻塞状态(进而再变为就绪)。
挂起状态的转换:
激活 (Activate):将进程从磁盘换入(Swap in) 内存。
挂起 (Suspend):将进程换出(Swap out) 到磁盘。
传统OS理论更完整的进程状态转换图😊
关键点总结😄
①状态是数字,管理靠队列:操作系统通过修改PCB中的一个状态变量,并将PCB放入不同的队列(运行队列、设备等待队列)来管理进程。
②进程竞争两类资源:
CPU资源:决定了进程在 运行(Running) 和 就绪(Ready) 状态之间切换。
外设资源:决定了进程从 运行(Running) 状态进入 阻塞(Blocked) 状态。
③挂起是内存管理手段:挂起状态是为了解决内存资源不足而设计的,它与进程是否等待、CPU或外设无关。一个进程无论原本是就绪还是阻塞,都可能因系统需要而被挂起。
④操作系统是唯一管理者:所有状态的变迁、队列的操作、换入换出,都是由操作系统这个“超级管理员”统一控制的。
Linux下的进程状态😋
前面了解到的进程状态是传统OS理论下的进程状态,接下来我们来在具体的操作系统(Linux)中认识进程状态~
通过前面我们知道Linux内核使用一个名为 task_struct
的结构体来管理每一个进程的所有信息,这个结构体非常庞大,包含了进程状态、进程ID、调度信息、内存管理、文件系统、信号处理等等所有资源。也就是我们经常说的 PCB(进程控制块)。
内核如何组织进程 - 双链表管理😍
我们知道操作系统对软硬件资源的管理总方法是【先描述,再组织】,那么具体来讲应该怎么样进行描述管理呢?
传统问题:如果我们在task_struct里直接定义struct task_struct *next, *prev;来组成链表,那么这个链表就只能连接task_struct。内核中有成千上万个不同类型的结构体都需要用链表连接,为每一种结构体都单独实现一套链表操作(增删改查)是代码的极大冗余,且难以维护。
Linux内核的解决方案:“侵入式链表”
它定义了一个独立的、与数据类型无关的链表结构 struct list_head
,它只包含两个指针:next
和 prev
。
struct list_head {struct list_head *next;struct list_head *prev;
};
任何一个需要被链表管理的结构体,只需要在里面嵌入一个 struct list_head
成员即可。
例如:
struct task_struct {// ... 无数其他的成员,如 state, pid, priority ...struct list_head tasks; // 嵌入的链表节点// ... 更多其他成员 ...
};
这样,所有进程的 task_struct
中的 tasks
节点,就可以通过 next
和 prev
指针连接成一个巨大的双链表。内核只需要维护这个 list_head
组成的链表即可。
关键技巧:如何通过链表节点找到它所属的整个结构体?
问题:我们遍历链表时,当前指针是 struct list_head *current_node;,但我们真正需要的是它外面的那个 struct task_struct。
解决原理:
假设结构体 struct task_struct 在内存地址 0x1000。
它的成员 tasks 的地址可能是 0x1040(因为前面有其他成员)。
tasks 的偏移量就是 0x40(0x1040 - 0x1000)。
知道了偏移量,如果我们知道了 tasks 的地址是 0x1040,用这个地址减去偏移量 0x40,就能得到外层结构体的起始地址 0x1000。
&((struct test*)0->c) 这个表达式就可以计算偏移量。它把地址0强制转换为struct test*类型,然后取它的成员c的地址。因为这个结构体的起始地址是0,所以成员c的地址在数值上就等于c在结构体内部的偏移量。
内核中的 container_of(ptr, type, member) 宏就是利用这个原理,通过成员指针ptr、结构体类型type和成员名member,完美地找到了外层结构体的起始地址。
这使得一套通用的链表操作代码可以管理内核中所有嵌入list_head的结构体,是Linux内核设计的精髓之一。
详细的进程状态🙃
我们来看看内核源代码中对进程状态的定义数组
看起来和我们传统的OS理论的进程状态类似但是还是有一些不一样~
为什么是 0, 1, 2, 4, 8...?
因为这些状态值实际上是位图(bitmap) 中的不同位,一个进程可能同时具有多个状态的特征(例如,既是停止状态又在等待磁盘IO),通过位运算可以组合判断。
下面我们详细解释每个状态:
R (Running - 运行状态):
并不意味着进程正在CPU上执行,更准确的理解是:它表示进程是可运行的。
进程要么正在CPU上运行,要么正躺在运行队列(run queue) 里等待被调度器选中。所以,您会看到很多状态为R的进程,但同一时刻只有一个进程在一个CPU核心上运行。
S (Sleeping - 可中断睡眠状态):
这是最常见的睡眠状态(浅度睡眠)。进程正在等待某个事件的完成,比如等待用户输入、等待网络数据、等待一个信号(Signal)。这种睡眠是可中断的,可以响应外部事件,如果此时给这个进程发送一个信号,它会提前被唤醒并响应这个信号。
接下来我们写个代码来看看:
运行程序同时命令行输入上一篇博客提到查看进程详细信息的命令:
有的小伙伴不禁好奇程序正在运行,但是是浅度睡眠状态?为什么?
这是因为sleep 系统调用和ps 命令的瞬时性。
sleep(2) 函数会使进程进入睡眠状态,等待指定的时间(2 秒),在睡眠期间,进程因为等待定时器超时这一事件而进入可中断睡眠状态,也就是 S 状态。ps 命令显示的是执行瞬间的进程状态,CPU的处理速度是很快的,运行 ps 命令时,有很大概率该进程正在执行 sleep(2) 调用,处于等待状态,因此被捕捉为 S 状态。状态后缀 + 的含义:
STAT 栏位中的 + 表示该进程是一个前台进程(foreground process)。这意味着它是在终端中直接启动的,并且可能接收终端的输入输出。
那么什么时候会是R状态呢?
当进程从 sleep
中唤醒,开始执行 printf
和下一次循环判断时,它处于运行队列中,状态会短暂地变为 R
。但由于 printf
和循环判断的操作非常快,而睡眠时间相对较长(2秒),进程绝大多数时间都处在睡眠状态。
D (Disk Sleep - 不可中断睡眠状态):
这是一种特殊的深度睡眠状态,通常发生在进程等待底层IO操作(特别是磁盘IO)完成时。它的核心特征是:不可中断,不对外部事件做任何的响应。甚至连 SIGKILL 信号【Kill(杀死)】/操作系统都无法杀死处于D状态的进程,除非进程自己醒来。这是为了保证在进程与磁盘进行关键数据交换时,不会被意外打断,从而避免数据损坏或文件系统不一致等严重问题。如果某个进程因为硬件(如坏掉的磁盘)或驱动bug而一直处于D状态,它会一直卡在那里,无法被杀死,通常只能通过重装整个系统来解除。
T (Stopped - 停止状态):
进程被暂停执行,通常是由信号控制的。发送 SIGSTOP 信号【Stop(暂停/停止)】可以让进程进入暂停状态;发送 SIGCONT 信号【Continue(继续)】可以让进程继续运行,从暂停状态恢复到运行状态。
在调试器(如gdb)中下一个断点,当进程执行到断点时,就会被调试器通过信号置于停止状态(T)。
如何看到T状态呢?这里有两种测试
①用 Ctrl+Z 暂停前台进程
最简单的方法是启动一个前台进程(如 ./code
),然后按下 Ctrl+Z。这会向进程发送 SIGTSTP 信号,立即将其置于停止状态(T)。使用【ps ajx | head -1 ; ps ajx | grep "code"】即可看到状态变为 "T"。
②使用 kill 命令发送停止信号
找到目标进程的 PID 后,可以通过命令显式发送停止信号~
除此之外,我们可以发现Ctrl+C无法终止进程了,这是因为Ctrl+C 的信号 (SIGINT) 只发送给当前终端的前台进程组(Foreground Process Group),而后台进程不属于这个组,因此它们根本接收不到这个信号。
我们也发现使用 ps 这类命令中无法看到进程的 X (dead) 状态。原因在于,X 状态是一个瞬时的、内部的内核状态,而不是一个可持续观察的进程状态。
这里也简单对比一下前台进程和后台进程:
特性 | 前台进程 | 后台进程 |
---|---|---|
启动方式 | ./code | ./code & 或 Ctrl+Z + bg |
终端控制 | 有,独占输入焦点 | 无,无法接收终端输入 |
接收 Ctrl+C | 可以 | 不可以 |
Shell 行为 | 等待进程结束 | 立即返回提示符 |
ps 状态中的 + | 通常有 S+ / R+ | 没有 + ,仅为 S / R |
同时也简单介绍一下Linux的kill信号,后面会详细讲解:
信号 | 编号 | 作用 | 可否被阻挡 | 结果 |
---|---|---|---|---|
SIGSTOP | 19 | 暂停进程 | 否 | 进程进入 T (stopped) 状态 |
SIGCONT | 18 | 继续被暂停的进程 | 是(但通常不会) | 进程恢复为 R/S (running/sleeping) 状态 |
SIGKILL | 9 | 终止进程 | 否 | 进程被立即杀死,资源回收 |
t (Tracing stop - 跟踪停止状态):
老的Linux内核版本会有t状态,与 T 状态类似,但特指进程正在被调试器跟踪而停止(例如被ptrace系统调用跟踪),调试器可以检查和控制进程的内部状态。
X (Dead - 死亡状态):
进程的生命周期已经结束,其占用的所有资源(包括task_struct)都已经被操作系统回收。这个状态只是一个瞬时状态,用户使用 ps 等命令是绝对看不到进程处于这个状态的。它只是一个内部状态返回值。
Z (Zombie - 僵尸状态):
这是进程生命周期中的一个重要且特殊的阶段~
我们首先来看看什么是僵尸进程~
僵尸进程👌
什么是僵尸进程?😊
当一个进程完成了它的任务(通过 exit()
系统调用终止),它需要通知其父进程自己执行的结果(退出状态码),在这个阶段,进程会进入僵尸状态。即子进程退出,父进程还在运行,但是父进程还没有读取子进程状态,子进程进入Z状态~
-
内核行为:进程退出时,内核会释放该进程的大部分资源(如内存、文件描述符等),但会保留其
task_struct
结构和退出状态信息。这是因为进程创建出来是为了完成任务的,我们需要知道任务完成得怎么样? -
僵尸进程的形成:保留这个
task_struct
就是为了让父进程可以通过wait()
或waitpid()
系统调用【后面的博客讲解】来读取子进程的退出信息。如果父进程一直没有来读取(“收尸”),这个已经死亡但保留着task_struct
的空壳进程就成为了“僵尸进程”。
为什么需要僵尸状态?🙃
这是一种设计上的机制,目的是保证进程退出信息不会丢失。父进程总是有机会知道子进程是正常退出还是异常崩溃,以及它的返回值是什么。
测试代码:
结果:
僵尸进程的危害👍
① 资源泄漏:task_struct 本身是一个内核数据结构,需要占用内核内存(虽然很小)。如果父进程大量创建子进程且从不回收,就会导致大量僵尸进程积累,耗尽系统的进程号(PID) 和内核内存资源,最终导致无法创建新的进程。
②无法被杀死:僵尸进程已经死亡,SIGKILL 信号对它无效,因为已经没有代码可以执行来响应这个信号了。清除僵尸进程的唯一方法是让其父进程调用 wait()。
既然僵尸进程有这么大的危害,那么我们应该怎么避免呢?后面的博客我们会详细讲解~
孤儿进程🎃
什么是孤儿进程?😐
如果父进程先于子进程退出,那么这些子进程就失去了父进程,它们被称为“孤儿进程”。
内核如何处理孤儿进程?🤔
操作系统设计了一个机制来防止孤儿进程无人照管:
Init进程领养:所有进程的祖先是init进程(现代Linux系统中通常是 systemd,PID=1)。当一个进程的父进程先退出时,内核会将这些孤儿进程的父进程ID(PPID)重新设置为1。
Init负责回收:Init进程会周期性地调用 wait() 系统调用来检查是否有子进程退出。因此,被它领养的孤儿进程在退出后,Init进程会负责回收其资源,从而避免了它们变成僵尸进程。
测试代码:
结果:
运行过程:
-
父进程创建子进程。
-
父进程打印信息后,睡眠1秒然后退出。
-
此时子进程才睡眠了1秒,还在继续睡眠。
-
子进程变成了孤儿进程。
-
Init进程(PID=1)成为它的新父进程。
-
子进程睡眠结束后也退出,由Init进程自动回收,不会留下僵尸。
僵尸进程和孤儿进程对比总结😭
特征 | 僵尸进程 (Zombie) | 孤儿进程 (Orphan) |
---|---|---|
成因 | 子死父不收 | 父死子还在 |
本质 | 进程已结束,但内核数据结构未释放 | 进程仍在运行,但父进程已结束 |
危害 | 占用内核资源(PID、内存),可能导致资源耗尽 | 本身无害 会被init领养并正常回收 |
处理方式 | 让父进程调用 wait() ;杀死父进程 | 无需处理 由init进程自动接管和回收 |
命令查看 | ps 状态显示为 Z | ps 查看其 PPID 为 1 |
♥♥♥本篇博客内容结束,期待与各位优秀程序员交流,有什么问题请私信♥♥♥
♥♥♥如果这一篇博客对你有帮助~别忘了点赞分享哦~♥♥♥
✨✨✨✨✨✨个人主页✨✨✨✨✨✨