一、操作系统教材中描述的进程状态

进程状态是操作系统管理多道程序运行的核心机制,其存在的根本原因是CPU与I/O设备的速度差异以及系统资源有限性
一般来说,进程可以分为以下的几种状态:创建状态、 就绪状态、运行状态、阻塞状态和结束状态。此处简单介绍:

  • 创建状态(新生状态):表示一个进程刚刚被创建,还未完成初始化,此时不能够被调度执行。
  • 就绪状态:进程初始化后,就立刻转为就绪状态,该状态表示进程可以被调度执行,但是还未被调度器选择(或者通俗点讲,就是还没有被内核选中,来使用CPU资源)。
  • 运行状态:表示进程正在使用CPU资源。
  • 阻塞状态:该状态表示进程需要等待某外部事件(如I/O事件),暂时无法被内核调度。
  • 终止状态(结束状态):表示进程已经结束。

在这里插入图片描述

接下来,我将讲述操作系统中两个关键的进程状态——运行状态、阻塞状态。


1.1、运行状态

在内核中,存在一种名为 调度队列(或称之为 运行队列) 的数据结构,该数据结构可以说与CPU强相关的。在一般的教材中,会将处于调度队列的进程设立为就绪状态。也就是说,处于就绪状态的进程对应的PCB都处于调度队列中。而正在使用CPU资源的进程,称之为处于运行状态
在这里插入图片描述

然而,也存在部分教材不区分就绪状态和运行状态,而是认为使用CPU资源的进程与处于调度队列的进程都称为处于运行状态。为什么会这样认为呢🤔?事实上,CPU的轮转调度速度极快,以至于可以近似看成所有进程并行使用CPU。因此我们也可以认为,凡是在调度队列中的进程也是处于运行状态

注意:在调度队列中排队的不是进程本身(内核数据结构 + 对应的代码与数据),而是进程对应的PCB


1.2、阻塞状态

在编写程序的过程中,我们一定会遇到这样的情况:当我们的程序执行到如scanfstd::cin等输入函数时,程序都会停止执行,等待用户输入数据,然后才会往下执行。
在这里插入图片描述

在此期间,我们的进程在等待什么呢🤔??目前来看,进程需要键盘中的数据(由用户输入的),此时,最直接关联的就是键盘,当键盘中有了数据,就会立刻将数据传递给进程,因此,可以说我们的进程在等待键盘!

宏观来看,键盘属于硬件,键盘中有了数据说明键盘已经准备就绪,即一个硬件就绪了。那么,一个硬件就绪了,谁最应该第一个知道🤔??当然是操作系统,因为操作系统是软硬件的管理者!!既然操作系统管理硬件,就必然绕不开“先描述,再组织”

在内核中,我们通过一个结构体来描述一个硬件的属性,并通过链表将其组织起来,以供操作系统管理(可以参考下图理解)。其中,在描述硬件的结构体中,存在着一个等待队列,它就是实现阻塞状态的关键。
在这里插入图片描述
当我们的进程执行到scanf时,该进程的PCB就会从调度队列中剥离出,随后插入对应的硬件结构体中的等待队列中,此时,我们就称该进程从运行状态转变为阻塞状态;待用户输入数据后,键盘就绪,PCB便从等待队列中剥离,回到调度队列中,随后,操作系统将读取到的数据拷贝到内存中,这道流程下来后,该进程就从阻塞状态转变为运行状态

综上,我们输出两个最终的结论:

  1. 每种硬件都有属于自己的队列,当前进程需要谁,操作系统就将对应的PCB放入谁的队列中
  2. 阻塞状态与运行状态的本质区别:进程的PCB在谁提供的队列中。在CPU提供的队列中,就处于运行状态;在其他外设提供的队列中,就处于阻塞状态。

1.3 、挂起

设想一个极端情况,当我们在主机中开启大量进程,直至主机的内存空间不足。操作系统发现自己的内存空间不足后,应该如何应对呢??

磁盘中,存在着一块swap分区。由于进程的PCB在内存中,当操作系统发现自己的内存空间不足后,便会选择性地将一些进程对应的代码与数据交换到swap分区,此时该进程就叫做被挂起
在这里插入图片描述
理想状态下,我们希望swap分区的大小等于内存的空间大小。swap分区的存在可以达到动态扩展内存的效果,在实际使用的内存比起标明的要大得多由于swap分区处于磁盘中,操作系统将内存中的数据拷贝到磁盘中,必然存在极大的时间开销,因此,swap分区的本质也可以看作是用时间换空间,一般也建议swap不要开太大,过大的swap分区会导致系统变慢。
在这里插入图片描述
被挂起前的进程,可能都处于不同的状态,操作系统往往会先将处于阻塞状态的进程挂起,因为它们暂时都在等待硬件就绪,这样的情况,该进程就被设置为阻塞挂起状态
当处于阻塞状态的进程全部都被挂起了,但是内存空间仍然不足,此时,处于CPU调度队列的基础还未被执行,操作系统就会选择将其挂起。我们可以将其称之为就绪挂起状态
即使这样子,内存空间还是不足,操作系统便会选择性地杀掉特定的进程。(当然,这是非常极端的场景了


二、Linux下具体的进程状态

以上所讲的内容,都是基于操作系统这门课程所讲解。那么将这些理论实际落地到一个具体的操作系统上,又会有什么样的变化呢?我们以LinuxOS为例。

在Linux中,主要有以下进程状态:R状态、S状态、D状态、T状态、X状态等。

在命令行操作中,我们可以通过ps -axj / -aux查看进程相关信息,其中就包括状态。

  • a:显示一个终端所有的进程,包括其他用户的进程。
  • x:显示没有控制终端的进程,例如后台运行的守护进程。
  • j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息。
  • u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等。

在这里插入图片描述

这些状态在内核中,又是如何表示的呢??

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 */
};

上述代码,就是进程状态,在Linux内核中的表示,它们虽然目前看着是一个字符串来表示进程状态,但是最终都会转化为后面注释中的整型。观察这些数字,不难发现,它们本质上就是运用了位图的思想来表示进程状态。

这样的思想,也可以运用到其他的操作系统中,也就是说,其实大部分的操作系统都是运用整型来表示进程状态。

那么,这些状态究竟表示什么含义呢??它们和我们之前讲的几个进程状态有什么异同呢??请看如下讲解。


2.1、R状态

R,即running,翻译过来就是运行状态。在Linux中,运行状态并不单单指使用CPU资源的进程的状态,处于调度队列中的进程,也算是进程状态。我们在讲解运行状态的内容时,也提起过这种思路。这里就没必要过多展现。


2.2、S状态 & D状态

S,即sleeping,翻译过来就是休眠状态,它意味着进程在等待事件完成。从这个描述来看,这不就是我们先前讲的阻塞状态吗?我们来看如下例子:
在这里插入图片描述
当我们的进程执行到std::cin时,进程便开始等待键盘就绪,此时查看该进程的信息,可以发现他刚好就是S状态!!这也验证了我们之前的猜想。

观察上图,有人会疑惑,S后面的+表示啥🤔??
+在此处表示前台进程。当我们开启前台进程后,shell外壳将不会再解释后面输入的指令。
在这里插入图片描述
此时,我们可以使用 ctrl+c 杀掉前台进程。

与前台进程相对应的就是后台进程,状态信息中,没有+的就是后台进程。在命令行中,我们只要在可执行文件名后添加&,该进程启动时默认为后台进程。
在这里插入图片描述
这里要注意,后台进程是无法使用ctrl+c杀死的。我们只能够通过kill指令将其杀死。(关于kill指令更加详细的内容,会在信号部分讲解)
在这里插入图片描述
回到S状态的话题,由于S状态可以被杀掉,因此,我们一般也将其称为浅度睡眠状态/可被中断休眠状态

可能会有人感到奇怪,上文中我们得知,S状态本质就是可以被杀掉的阻塞状态,难道还有不可以被杀掉的阻塞状态吗🤔??对,就是我们接下来要讲的 D状态

D状态,属于Linux特有的一个休眠状态,英文全名为disk sleeping,即深度睡眠,我们也可以将其称为不可中断休眠状态。它与S状态最直接的区别就是处于D状态的进程不能被操作系统或信号杀掉。一般,进程在与磁盘IO的时候,就会处于D状态,当IO结束后,磁盘返回对应信号时,进程才会从D状态苏醒。出现D状态一般意味着这两种情况:1️⃣硬件出现问题;2️⃣IO的数据量过于庞大。

为什么会存在D状态呢🤔??
假设我们当前内存中有约500MB的数据需要写入磁盘中,在我们将这写数据写入进磁盘的过程中,一旦被信号终止,就可能会发生数据丢失或者损害文件系统等问题。

综上,我们可以发现,操作系统教材中的阻塞状态这一概念,在Linux当中其实是被拆分成S状态和D状态了。换句话说,我们可以简单地理解为S状态+D状态=阻塞状态


2.3、T状态 & t状态

T状态,全称Stopped,译为暂停状态。该理解该状态,就不得不牵扯到信号。在Linux主机上,我们可以使用kill -l指令,查看Linux内核提供的信号。本节,我们专门要讲解的就是 18信号(SIGCONT19信号(SIGSTOP
在这里插入图片描述
SIGSTOP是将一个进程设为T状态,而SIGCONT与其相反,它用于解除T状态。
在这里插入图片描述
在这里插入图片描述

此外,后台进程由于不能够读取键盘中的数据,因此,如果将一个需要从键盘在读取数据的进程放入后台,当它开始从键盘读取数据的时候,操作系统会自动将其设为T状态。
在这里插入图片描述
此外,与T状态类似的还有t状态,它也被称为追踪暂停状态。简单来说,当一个进程被追踪的时候,就会处于该状态。那么,什么叫做该进程被追踪呢??

在编写代码的时候,我们一定会遇到不同的bug,当代码量小的时候,我们可能会使用调试器进行调试。在Linux环境下,提供了调试器,cgdbgdb,当我们使用调试器调试一个进程时,调试器便会进入调试状态(注意,此时调试器便会被创建为一个进程,该进程处于R+状态),被调试的进程便处于t状态。t状态的使用场景较小,我们不再深挖。
在这里插入图片描述


2.4、X状态 & Z状态

X状态,全称dead,译为死亡状态。对应理论上的说法,就是终止状态。X状态是一种瞬时状态,我们使用ps axj指令查看时,往往会查不到。

Z状态,全称zombie,即僵尸状态。第一次听到这个概念可能会感到疑惑,这个状态有何作用,它与X状态之间又有什么联系呢🤔??

一个进程的创建,一定是为了完成某一个任务。那么你作为用户怎么知道某一个进程完成了它的任务呢??在Linux中,一个进程退出时,内核会将一个进程设为Z状态,等到回收完毕Z状态的进程的相关信息后,并将对应进程设为X状态。

看完上面的解释,你一定还充满疑惑,我们依次解答:

  1. 内核如何知道一个进程完成了任务🤔?
    进程退出后,会产生对应的退出信息,内核检测到退出信息,就知道该进程中止了(不一定完成任务了)。那么这个退出信息是什么呢?进程可以说是一个正在运行的程序,既然是一个程序,它最终一定会在main函数中执行return 返回值,或执行exit(),这个main的返回值或exit()的参数就是该进程的退出信息;此外,进程的退出信息还可以是进程收到的信号值,往往发生在处于运行过程中的进程被信号中断的情况。

  2. 进程退出后,退出信息保存在哪里呢🤔?
    进程退出后,它的task_struct结构体仍然会保存在内存当中,而对应的数据与代码则会释放,此时该进程就处于Z状态了。而该进程对应的退出信息,就会保留在task_struct中(task_struct中会存在int exit_state;int exit_codeint exit_signal这样的字段,这就是我们的退出信息)。因此,检测Z状态进程,本质就是在检测task_struct内部的相关数据

  3. 如何回收处于Z状态的进程,谁来回收🤔?
    当内核检测到某个进程处于Z状态后,便会让其父进程对该进程进行回收,回收本质就是获取子进程的退出信息,然后释放对应的task_struct。在父进程的代码中,我们可以通过waitpid()系统调用来回收子进程,关于该系统调用更加详细的信息,将会在进程控制中讲解。

  4. 假如父进程不对子进程进行回收呢🤔?
    当一个进程终止,如果不对该进程做任何回收操作,那么该进程就会一直处于Z状态。因此,子进程将会永远处于Z状态,这就意味着task_struct永远都不会被释放,即占据内存空间。我们都学过内存管理,这里发生的事就是我们曾经讲过的内存泄漏。可是有人又会问了,只要父进程结束,并且正确回收了,这样的内存泄漏不就不复存在了吗?这个想法没问题,但是,大部分软件都是不关闭的,它们底层就是一个死循环,这样的进程称为常驻进程。因此,只要不回收子进程,内存泄漏的问题就会一直存在,并且越来越严重,直到物理内存被占满导致服务器崩溃。所以我们一定要注意回收子进程

综上,一个进程执行完毕后,大致会发生如下事件:

子进程任务执行完毕,内核释放子进程在内存中的代码与数据,但保留子进程的task_struct(退出信息包含在该PCB内),并将子进程状态修改为Z状态。父进程执行wait()等待并回收子进程时,检测子进程对应的task_struct内的数据,发现处于Z状态,于是内核将子进程的退出信息给到父进程,然后将子进程状态修改为X状态,并释放子进程的task_struct。

至此,值得关注的进程状态全部讲解完成。最后,我们用一张图来体现Linux的进程状态转化:
在这里插入图片描述


2.5、孤儿进程

前文中,我们了解到了父进程回收子进程这一操作,在此基础上,我们再来认识一种进程——孤儿进程

直接输出定义:当父进程先退出,子进程仍然再执行时,该子进程就被称为孤儿进程

我们通过一个实验,来观察孤儿进程:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main()
{
        pid_t n = fork();       // 创建子进程
        if(n < 0)
        {
                std::cout << "fork failed ..." << std::endl;
                exit(-1);
        }
        else if(n == 0)
        {
                while(true)
                {
                        std::cout << "I am child process ..." << std::endl;
                        sleep(1);
                }
        }

        int count = 5;
        while(count)
        {
                std::cout << "I am parent process ..." << std::endl;
                count--;
                sleep(1);
        }
        std::cout << "bye ~" << std::endl;
        return 0;
}

在这里插入图片描述
上图是我截取的实验现象,观察可得:

  1. 父进程结束后,立刻被bash回收,没有观察到Z状态。(正常现象)
  2. 孤儿进程的ppid变为1,并且,变为后台进程

首先,我们要说明,pid=1的进程是systemd或者init,在不同系统下,名称会有变化,由于其pid的特殊性,我们也可以称之为1号进程

1号进程是Linux内核启动的第一个用户空间进程,PID为1,通常为init或systemd。它是所有进程的祖先,负责系统初始化、启动服务并收养孤儿进程,不能被普通方式杀死。(来源自DeepSeek🐳)

对其有了初步了解后,我们直接输出结论:💧除了1号进程外,任何一个进程都需要一个父进程。当出现孤儿进程时,必须被1号进程领养,并且,内核会自动将孤儿进程设为后台进程。💧

由于孤儿进程会自动变成后台进程,因此,我们只能够使用kill将其杀掉
在这里插入图片描述

思考这样一个问题:如果孤儿进程不被领养会发生什么呢🤔??

该进程就没有对应的父进程,也就是说没有对应的进程对它回收,就会发生先前我们讲的内存泄漏问题。


补充内容

本节主要讲解了进程状态相关的知识,但除此之外,我们也多次提出了前台进程与后台进程相关的概念。那么它们最核心地区别是什么呢??

我们直接输出结论:谁能够从键盘中获取数据输入,谁就是前台进程

为什么这么说呢??因为在一台主机上往往只有一个键盘,所以在获取输入的时候,只有一个进程能够获取数据。而这个获取数据的进程就是前台进程。

💧前台进程任何时刻只能够有一个!!后台进程能够有无数多个!💧


完🌑🌒🌓🌔🌕🌖🌗🌘🌑

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐