有了上一篇关于进程的初步认识和我们的PCB第一个数据段–标识符的讲解,接下来我们将继续讲解PCB的其他数据段,本篇要讲的是进程状态。
就像我们写贪吃蛇的时候,构建的游戏状态来判定游戏结束的方式一样,进程状态同理也是用来表明一个进程所处的状态的量。
我们常见的关于进程状态的解释图应该是这样的:
我们会看到,有创建,就绪,阻塞等等一系列其他的进程,但是这些说法都不够具体,下面让我们跳开书本,以一种更加具体的方式去看待进程。
在操作系统中,所谓的进程状态,其实指的就是PCB的一个字段,或者说,就是PCB的一个变量:int status,这个我们在上一篇进程的初步介绍中提到过,如下:
#define NEW 1
#define RUNNING 2
#define BLOCK 3
.......//等等其他各种状态
也就是说,所谓的进程状态的变量,其实就是利用预处理指令将数字定义成一堆状态,通过pcb->status=各种状态量,来控制状态,再利用if语句来对状态进行判断,从而执行对应的语句,比如将PCB放入对应的队列中。
因此,我们总结出:
故所谓的进程状态变化,本质上就是修改状态的整型变量,然后通过if的判断将PCB放入到对应的进程队列当中,按照OS的调度执行。
我们的分类和教材略有不同,我这里重点将其分为3个重要的状态:
1.运行状态
2.阻塞状态
3.挂起状态
接下来,我们将依次去讲解分析这三种重要的状态:
1.首先,我们需要弄清楚,什么是运行状态呢?
只要是再运行队列中的进程,其状态都是运行状态,这些进程已经准备好了,可以随时根据OS的指令被调度或者被处理。
2.那么什么是运行队列呢?
在计算机中,CPU是运算速度最快的硬件,每一个CPU在系统层面,都维护着一个运行队列,且注意,每一个CPU只维护一个,因此,有几个CPU,就可以同时维护几条运行队列。其大致的维护方式如下图:
因此,一个进程在运行状态的时候,一定是其PCB被链接到运行队列里面的时候,我们称这个时候的进程状态为运行状态。
不论我们写怎样的代码,都或多或少的需要访问计算机中的软硬件资源,也就是说我们或多或少都需要去访问系统中的一些资源。
比如:
我们需要从键盘,磁盘或者网卡等硬件设备中获取资源,当我们使用scanf或者cin的时候,我们的本质是想从键盘中读取一定的数据。
但是,倘若我们的程序开始后,我们没有向键盘输入数据呢?
此时,键盘上的数据是没有的,也就是没有准备就绪,操作系统此时会在第一时间实时掌控我们设备的状态,并且一定第一时间就知道所管理设备的状态变化,并将其反馈给我们的上层用户,因此由于此时进程想要访问的数据迟迟没有就绪,不具备访问条件,因此进程的代码就没法继续向后进行,在这个状态下,就会使进程进入阻塞状态,由前面讲到的运行状态的知识,我们也可以推出,我们所谓的阻塞状态也应该是将对应的PCB链接到某个队列中。
对于OS来说,涉及到管理,就必然需要先描述再组织的管理方式,因此,**OS想要管理硬件,也是通过构建关于硬件的属性的结构体来管理的,**其结构体大致如下:
struct dev
{
int type;//对应设备的种类,即哪种硬件设备。
int status;//设备状态
....//其他更多属性
PCB*wait_queue//设备的等待队列
};
不同的设备也是通过结构体中的type来重定义整型数据区分的,如下:
#define DISK 1
#define KEYBOARD 2
#define NETCARD 3
.......//让对应的整型数字,都对应一个相应的硬件设备,每一个硬件设备都对应的一个编号用来区分不同设备
在这里,你会发现一个PCB*wait_queue,没错,对于每一种硬件设备来说,他们都维护着一条自己的等待队列,和CPU维护的运行队列一样,当我们的PCB由于资源没有准备就绪而无法推进接下来的代码的时候,这个PCB就会被操作系统链接到对应设备的PCB等待队列中,我们称此时的这个进程的状态即为阻塞状态。
因此,我们可以这样总结:在操作系统中,会存在非常多的队列,运行队列,等待队列等,但是我们要明确,不是仅仅在CPU中维护着一条运行队列,在对应的每一个硬件设备中都维护着自己的等待队列
!!!故:我们的进程状态变化的本质:
1.更改PCB.status整型变量的数值
2.将PCB链入到不同的进程队列当中!!!
在上述的讲解中,你会发现:我们所有的状态变化,都只与进程的PCB有关,和进程对应的代码和数据没关系。
将一个进程的PCB由运行队列链接到某个硬件对应的等待队列:该进程阻塞了
将一个进程的PCB由等待队列链接到运行队列:该进程被重新唤醒了
可以用下面的图片来表示进程处于阻塞状态:
那么,阻塞状态,站在我们用户的视角是怎样呈现的呢?
1.首先是运行的程序会卡住
2.pcb没有在运行队列中,且进程的运行状态不是running,CPU不会调度该进程,该进程的代码无法向下继续推进
因此,这也是为什么我们在同时打开多个软件的时候会卡住的原因,其本质原因就是同时开了多进程导致的
操作系统的进程队列采取的是双向链表链接起来的,故不存在所谓的PCB丢失的问题,OS操作系统也会为其托底,保证PCB不会发生丢失。
挂起状态是基于阻塞状态的情形下出现的一种情况:我在这里所说的正是阻塞挂起状态。
如果一个进程当前被阻塞,这就注定在这个进程所等待的资源没有就绪的这段时间内,进程是无法被调用的,但是它的PCB却占据着内存的空间,如果此时的OS的内存的资源已经严重不足的时候,应当如何处理呢?
首先我们需要搞清楚一个问题是,OS直接崩溃和OS中的一个进程中止掉这两个情况哪一个更严重,我想不用我说,我们都知道,OS崩溃造成的后果是更严重的,OS也是这样考虑的,因此,在内存资源严重不足的时候,OS会将阻塞进程对应的代码和数据手动置换到磁盘中,然后释放掉这个PCB空间,从而维持OS的正常运行,此时的这个PCB就是挂起状态。如下图:
或许你会问,将内存数据进行置换到外设,这时针对所有的阻塞进程的,这就会导致运行速度缓慢,但速度变慢是必然的,我们只要保住了我们的OS正常运行就是最关键的,千万不能让OS停止执行,那样就麻烦更大了
而被置换到磁盘的代码和数据会被存储到磁盘的一个叫swap的分区内,当OS解决了内存不足的问题,又可以正常运行的时候,这些在swap上的数据会被重新加载回到内存中并且重新匹配新的PCB为其调度。注意,我们的swap不要开太大的空间,这回导致操作系统过分依赖磁盘交换节省空间,从而导致程序的运行速度变慢。
我在这里所说的,是挂起的其中一种情况:阻塞挂起,也是最常见的一种情况,而挂起的一系列处理过程,都是由操作系统单独完成的,即寻找阻塞进程,数据置换,内存清理,数据重新加载并重新匹配PCB。
其实,不论教科书上是怎样的名字,我们对应的进程状态实际上都是这三种状态的衍生,比如第一张图片的创建,就绪,衍生其实就是运行状态,阻塞就是阻塞状态,而中止就可以理解为挂起状态,所以我们对于任何一种进程状态,都可以将其分解为三种基本状态或者为三种基本状态的组合。
虽然理论上,我们谈到了基本的三种状态,但在LINUX的系统的实际环境下,还是衍生出了各种不同的进程状态:
1.前台状态"+"
2.后台状态
3.浅度休眠状态“S”
4.深度休眠状态“D”
5.暂停状态”T"
6.调试追踪暂停状态“t"
7.死亡状态”X"
8.僵尸状态“Z”
9.孤儿状态
接下来,我们将分别去学习这几种状态:
如图所示,我们的STAT即代表进程状态,这种带有+号的即为前台进程状态,这种是不需要去关注后面的字母的,有了+号就代表是前台状态。
前台状态的特点是:
进程一直不断的向前走,一个系统中前台进程有且只有一个,只有用^c才能中止。
这种不带有+号的即为后台进程,想要后台执行一个进程,只需要在可执行程序后面加上&号即可,后台进程在运行是可以输入其他指令和代码,且^c是无法中止的,用kill -9即可中止进程或者等待程序自己结束。
它的本质就是阻塞状态,或者是死循环的状态,在LINUX中,这种休眠的进程被称为浅度睡眠,浅度睡眠的特点是会对外部信号做出响应。
比如,你可以直接利用kill指令来杀死进程,如下:
这里的S便代表一个休眠进程,我们是可以通过kill将其杀死的。
这是一种专门针对磁盘IO拷贝设计的一种进程状态,在这条进程没有执行完之前,OS是无权杀掉这个进程的,哪怕内存空间不够,因为有的时候这个进程可能涉及到将重要的数据放入到文件中,数据传输需要时间,如果这时进程被杀死,对应的数据就会丢失,如果是很重要的数据,就会造成巨大的损失,因此,深度休眠正是为此而生,它保证了数据不会发生丢失。
不过,一旦到了连深度休眠的进程都想要删除的地步,这个OS也基本处于崩溃边缘了,这个时候除非拔网线,否则OS就要崩溃了,但拔网线同样会导致数据丢失,因此,深度休眠基本是见不到的。
T进程是需要我们通过信号让进程响应才可以触发的,我之前提到过信号的查询kill -l
如下:
我们已经认识了9号信号SIGKILL,今天让我们再认识两个信号:18号SIGCONT 19号SIGSTOP
18号信号是进程重启信号,它可以让一个被暂停的进程重新运行
19号信号是进程暂停信号,它可以让一个进程暂停下来编程暂停进程:T进程
如下:
如图所示,pid为9989的进程变成了暂停进程,停止了运行,现在我们可以让其复原,只需要输入对应的kill -18信号即可。
由此我们引发了一个问题:我们为什么要暂停进程呢?
在进程访问软件资源的时候,可能由于权限问题,暂时不让进程进行访问,此时就会将进程设为T状态
追踪暂停用于我们调试的时候,在我们设置断点后,再debug模式下,我们的代码遇到断点就会自动进入暂停进程状态,之后再运行则会重启进程,如下:
如上图,这便是我在7处设置了断点,因此当我run的时候,会自动运行到断点处进入暂停状态,此时进程的状态就是t状态,而后我再一次run,则进程又变成了运行状态。
因此我们可以总结:
本质上T/t也算阻塞状态,等待软件准备就绪,它也会创建一个关于软件的PCB*wait_queue进程,,而我们调试gdb也是一款软件,因此调试的时候也会打开一个进程gdb,gdb进程后面还会接着一个我们自己的可执行程序,当gdb进程没有执行完的时候,就会将可执行程序进程PCB链接到wait_queue中,等到debug执行完后再回到运行队列中,因此进程也会等待进程。
即PCB被OS释放回收后,这个进程就变成了X状态,即瞬间死亡状态。
何为僵尸进程呢?根据字面的理解,我们可以理解为半死不活的状态。
它的概念是:
进程被中止,但是它对应的PCB的信息还没有被采集完毕的状态。
如下:
此时的mybin对应的便是僵尸进程。
想要真正认识僵尸进程,我们需要重新理解一下进程:
首先进程-内核PCB+进程对应的代码和数据,在内存中,这两者都要占用内存空间。而进程退出的核心工作之一:将PCB和自己的数据和代码释放掉。
那首先我们创建一个进程的目的是为了帮助我们完成某些任务,那如何知道进程完成的好坏呢?我们就需要反馈的数据和信息,进程在退出的时候就需要一些退出信息来表明自己的任务完成如何,然后由父进程来查看收集这个信息从而反馈给上层。
因此,当进程退出的时候,退出的信息会被写入到它对应的退出进程的PCB中,而代码和数据是可以允许被释放的,但是这个PCB不允许被立刻释放,必须等到信息被收集完才能释放,从而让父进程知道这个子进程退出的情况和退出原因。
因此,一个进程退出了,但是还没有被父进程读取数据,则OS必须来维护这个退出进程的PCB结构直到数据被读取,在父进程读取之前,这个进程没有彻底退出,但是又不能被调度,这个进程就是僵尸进程,僵尸进程的数据被父进程读取后,才会由Z状态变为X状态,之后这个进程才会被彻底释放掉,这样我们对僵尸进程的概念才更加清楚了
那如果父进程一直不回收退出的子进程,则这个PCB就会一直维持Z状态并且一直存在么?
让我们来看下面的代码:
退出一个进程,我们要使用exit(状态参数)来退出,它的头文件为
倘若父进程一直都在执行,而子进程执行了几次就被回收了,那么父进程一直执行自己的代码,不会回收子进程,这个子进程的PCB就会一直为僵尸进程么?
结果如下:
是的,父进程一直在执行,同时子进程的PCB也会变成僵尸状态,我们直到PCB是会占用内存空间,因此,如果不及时释放我们的僵尸进程,就会造成内存泄漏的问题,就像我们动态开辟如果不及时free的话,也会造成堆区内存的泄漏,最后导致程序卡顿甚至最后崩溃,而僵尸进程更是会导致内核级别的内存泄露问题。
此时我们会发现,我们的僵尸进程为defunct,即为失效的意思
!!!!僵尸进程的危害:
若一个父进程创建了很多个子进程,但就是不回收,就会造成内存资源的浪费,因为数据结构对象本身就要占用内存,所以会造成内存泄漏的问题,我们后面会介绍如何解决这个问题。
倘若存在一个父子进程,父进程已经退出了,父进程会被bash直接回收,但是这种回收是一级管一级的,也就是说bash可以回收父进程,但子进程必须有父进程回收,bash是不会回收子进程的,bash会及时回收父进程,只要父进程要退出bash就会及时回收,因此bash不会出现僵尸进程的问题,但此时的子进程的父进程退出了,子进程没人回收,就成为了孤儿进程,对于孤儿进程,他们统一会被1号进程,你可以理解为操作系统本身领养托管,system/initd。从而防止子进程在进程退出后变成僵尸进程无人回收造成严重的内存泄漏问题。
如下:
在这段程序执行的过程中,我主动kill掉父进程,则子进程正常运行,但显示如下:
没错,你会发现他的父进程的ppid变成了1,说明此时这个子进程成为了孤儿进程被系统托管了,直到进程结束直接被系统释放掉。
以上便是我们的PCB第二个重要的数据段:进程状态,之后我们就会讲解下一个数据段:进程优先级,其实进程状态的本质就是PCB被链接到不同的队列中,希望大家可以再进一步理解一下进程状态,并且最好可以同我们使用电脑时的一些实际情况联系起来,从而获得更好的理解。5