一、进程状态
1. 什么是状态?
举个例子:渴了,就要喝水,饿了,就要吃饭。
所以,状态决定了进程接下来要做的工作。
先有一个普遍的认识。不管什么操作系统,状态本质上就是一个数字
。这个数字是几就表明了它的状态。
例如:
#define RUNNING 1
#define BLOCK 2
2. 状态的分类
那么,进程状态都有哪些呢?
所有的操作系统,在实现进程状态变化的时候,都要符合上面的理论。
在冯诺依曼体系结构中,有五个单元,输入输出设备,内存,运算器,控制器。而输入输出设备统称为外设资源,运算器,控制器统称为CPU资源。进程在运行的时候是需要资源的,也就是说,进程之间是需要竞争的,竞争的资源分为两大类:外设资源,CPU资源
。
所有的进程都要竞争CPU资源,那么,在大部分的操作系统内部都要给每一个CPU设置一个调度队列
。
多个CPU就有多个调度队列,那么操作系统要不要对调度队列进行管理呢?答案是要的
。怎么管理?先描述在组织
。
那么在操作系统内部就要有一个struct runqueue。
struct runqueue
{
//队列属性
int num; //多少个进程
struct task_struct* head;//后面修正,其实并不是以struct task_struct* head来调度进程的
}
3. linux中进程是如何以双链表的形式管理的?
那么,CPU的调度队列是如何拿到该进程的呢?这就不得不细说进程在linux中是如何以双链表的形式管理的。
struct task_struct
{
//所有属性
struct list_head tasks;
}
struct list_head
{
struct list_head* next;
struct list_head* prev;
}
在struct task_struct里有一个struct list_head tasks变量,struct list_head里包含next,prev指针,它是通过struct list_head来实现双链表的。也就是说,我们只能知道struct task_struct结构体中的struct list_head tasks变量的地址
,那么,我们要如何得到struct task-struct变量的起始地址?如何访问struct task_struct变量内部的任意一个属性呢?
扩展思路:如果这是一个数组,不是结构体呢。我们知道数组中任意一个元素的地址,是否可以算出数组的起始地址呢?可以的。起始地址 = 该地址 - 该地址偏移量。
那么,对于struct task_struct结构体而言,我们已经知道了struct list_head变量的地址,struct task_struct的起始地址 = struct list_head变量的地址 - 偏移量就可以了。struct task_struct中任意属性的地址 = struct task_struct起始地址 + 属性类型的大小就可以了
。
现在的问题就转化为应该如何求得偏移量?
//这就是偏移量
//解释:在地址为0处有一个struct task_struct变量,
//去访问该变量中的struct list_head变量并进行取地址
&((struct task_struct*)0->tasks)
对于每个进程来说,struct task_struct结构体的大小是固体的,struct list_head在struct task_struct中的偏移量也是固定的。
那么,struct task_struct* start = &struct list_head tasks - &((struct task_struct*)0->tasks)
。
对比于我们学习过的双链表,你知道这意味着什么吗?
linux中PCB实现双链表的这种方式,再也与类型无关了
。如果struct task_struct里有非常多的这种节点,也就是说,struct task_struct既可以属于链表,同时也可以属于其他数据结构
。
struct runqueue
{
//队列属性
int num;
struct list_head node;
}
这样的话,进程就不用从自己的双链表中断裂再链入到调度队列里。
4. 详解进程状态
在linux中,是没有就绪状态的。所以我们统一认为就绪和运行不分家。在有些系统中,认为PCB处在运行队列中但未分配CPU资源叫做就绪状态,正在CPU上执行的进程叫做运行状态。
每个进程都要竞争CPU资源,那么你得把进程链入到调度队列当中。我们把这种行为叫做进程在CPU的调度队列当中进行排队。而进行排队时,进程的状态就叫做(r)运行状态
。
运行状态的定义:该进程的PCB必须处在CPU的调度队列当中。只要在调度队列中,进程就叫做运行状态。随时等待CPU调度执行
。
阻塞状态:当进程无法继续执行,需要等待某些非CPU资源就绪时(等待用户输入,磁盘I/O操作完成…),就会进入阻塞状态。
特征:进程进入阻塞状态,PCB会被移除运行队列,进入等待队列
。
等待队列中也是通过struct list_head将进程PCB关联的。
阻塞和运行状态的本质:
1.更改struct task_struct状态的属性
2.链入不同的队列中
挂起状态:当内存空间严重不足时,操作系统会将进程的代码、数据、堆栈等内存资源移出内存,换出到磁盘的交换区里(swap),但进程的PCB仍保留在内存里,叫做挂起状态
。
阻塞挂起:进程因等待非CPU资源并且内存不足时,就会将进程PCB链入等待队列里,进程的代码,数据,堆栈等内存资源被换出到磁盘的交换区里。
进程恢复之后,PCB会被重新链入运行队列里,代码,数据被重新加载到内存里。
新建状态:操作系统为进程分配必要的资源(将代码,数据拷贝到内存里,创建PCB结构体等),并为进程分配唯一的标识符(pid)。
5. 验证进程状态
先来看看linux源码是怎么定义状态的。
我们用程序来进行演示说明。
进程已经运行,为什么进程状态是S,不应该是R吗?
我们对代码做出一点修改,再看一次结果。
这是为什么呢?在解决这个问题之前,先来看看S代表的是什么状态。
S代表的就是浅度睡眠(阻塞状态)
。现在回答上一个问题。它为什么是S不是R呢?printf是向显示器打印内容的,访问的是外设资源,外设的速度很慢,大概率资源是无法就绪的,所以是S(阻塞状态)
。有很少的概率会显示R。
既然S是浅度睡眠,那D又是怎么回事呢?D是深度睡眠。这个样例无法演示。我们举个例子来理解什么是深度睡眠。
在说明深度睡眠之前,看一下浅度睡眠有什么特点。
通过观察可以发现,浅度睡眠可以相应外界事件
,我们可以使用Ctrl + c来杀掉浅度睡眠的进程。
例子:假设我们的A进程要向磁盘写入1TB的数据,写入成功还是失败都需要给A进程一个反馈。这时A进程就会进入S状态,等待磁盘的反馈。此时,内存资源严重不足,操作系统就把A进程的代码,数据…换出到磁盘交换区里,后来磁盘写入失败,它想给A进程反馈,但是A进程已经拿不到反馈了。磁盘索性也不管了,因为其它进程可能也需要使用磁盘。
如果这是向银行转账1个亿呢,那岂不是事大了。行长就要过来找它们三个来背锅。磁盘就要说话了,写入成功还是失败这都是有可能的,我都说让A进程等我反馈了,你为什么不等。A进程也很委屈啊,我一直在等你,可是操作系统要干掉我,我有什么办法,我也是受害者啊。操作系统说,我都快要挂了,我只能干掉你啊,这是当时给我的权利啊。行长看了三个人一眼,说,怪我吧。以后制定一个规则,凡是浅度睡眠的进程你可以杀掉它,但是它要是深度睡眠,你不能杀掉它。
总结:深度睡眠不对任何事件进行响应。OS(操作系统)也杀不掉我
。除非进程自己醒来。
.
S,D都叫做阻塞状态。
.
T暂停状态(因为进程访问非法资源,操作系统不想杀死该进程)
kill 向进程发送信号
眼尖的伙伴就发现了,这时候R少了一个+号,那我们就来说明一下,有+号的代表该进程是前台进程,可以用Ctrl + c终止进程,反之为后台进程。
.
t追踪(进程因为断点而停下来)
在linux中,进程结束分为两种状态:X(进程已完全终止)和Z(僵尸状态)
。
在正式讨论进程结束这个话题前,有一个问题需要解决:为什么要创建进程?
创建进程的目的就是为了完成任务。那么进程结束时,任务完成的怎么样呢?
如果今天我们写的是冒泡排序,我们可以根据排序的结果来判断任务完成的怎么样。但如果我们写的程序结果是不可显示的呢?要如何判断。
还记得main函数最后一条语句吗,我们总是return 0。但其实很多人并不知道为什么。今天我们就可以简单的了解一下。main函数它要去调用别人,将来它也要被别人调用。它的返回值其实是返回给它的父进程的,父进程要知道它把任务完成的怎么样了。0代表的就是成功,非0代表失败,数字不同代表失败的原因不同。也就是说,进程结束,不能立即释放所有资源
。
举个例子:今天早上你在晨跑,突然有一个人风驰电掣的从你身边跑过,在你前方不远处突然倒下。这时候你很慌,你上前去摸他的气息,发现他没了。这时你给110,120打电话,警察来了会立即将他抬走吗。当然不会,法医,警察得采集他的信息,知道他为何没了。
进程也是一样的。进程结束,要先处于一种僵尸状态,代码,数据释放掉,但是会保留struct task_struct。PCB会自动记录进程退出时的退出信息,方便父进程读取退出码
。
模拟僵尸状态。
我们将这种进程结束,但保留PCB的进程叫做僵尸进程。如果父进程将来不处理,僵尸进程会一直存在
。也就是说,PCB会一直存在于内存中。你知道这意味着什么吗?这会造成内存泄漏
。
一个小插曲:大家思考一个问题,进程 = PCB(内核数据结构) + 自己的代码和数据。那么,是先有PCB还是代码和数据?
我们对操作系统的高度概括是先描述,在组织
。那肯定是先有PCB了。
上面我们说的进程结束话题,都是基于子进程先退出的情况,那么如果是父进程先退出呢?
可以看到,父进程先退出的话,子进程就变为了孤儿进程
。子进程的父进程变为了1号进程。那么此时就要有几个问题了。
1.那么1号进程是谁啊?
2.为什么要领养子进程啊?
3.不是说好进程退出时会变为僵尸进程,为什么没有看到父进程的僵尸状态呢?
我们依次来回答这三个问题。
第一个问题:子进程被操作系统1号进程领养了。它叫做init进程
。
第二个问题:孤儿进程也会退出,但是父进程没了,被系统领养,本质上是为了回收孤儿进程,防止内存泄漏
。
第三个问题:
可以看到,子进程的父进程的父进程是bash(外壳程序),父进程一旦退出,就会被bash回收,所以看不到父进程的僵尸状态
。
二、进程的优先级
正式了解优先级之前,先回答几个问题?
1.什么是优先级?
进程得到某种资源的先后顺序
。
这就像食堂打饭,谁优先打饭,谁后面打饭。
2.为什么要有优先级?
本质:资源少,进程多
。
例如:食堂打饭的窗口是有限的,但是学生人数很多,为了争抢资源,所以才要排队。
3.linux优先级怎么做的?
这里我们说的是进程竞争CPU资源。
ps -al //查看所有进程的优先级
看看linux是如何实现的?
PRI代表的就是优先级,这个数字越小代表该进程优先级越高。它就是一个整数,存在于task_struct里。那么,NI又是怎么回事呢?NI(nice)叫做修正数据,PRI(new) = PRI(old) + nice
。
在linux中,nice的取值范围是 -20 至 19,一共40个级别。
1.为什么要有范围?
我们所用的操作系统基本上都是分时操作系统,之所以要有范围,就是为了尽可能的做到公平公正,这也意味着,优先级要变也要在可控的范围内变化
。
举个例子:如果今天你在食堂排队,本来一切井然有序,但是老有人在你前面插队,你就一直无法打饭。
进程也是一样的。nice之所以要有范围,就是为了保证进程公平公正的进行调度,否则某些进程长时间得不到调度,就会造成饥饿问题。
既然nice有范围,那也就意味着进程的优先级也是有范围的。那么进程的范围是多少呢?
PRI(new) = PRI(old) + nice
。
要想知道进程的范围就必须知道PRI(old)是多少了。
我们通过改变nice来得到PRI,进而得到PRI(old)。
top
-r //输入进程pid,接着输入nice值
第一次使用top命令将nice值改为-10
第二次使用top命令将nice值改为10
通过观察可以看到,PRI(old)为80
,因为PRI(new)并没有在第一次的基础上进行改变。
那么进程的优先级取值范围是 60 至 99,一共40个级别。
2.为什么是[-20,19]?
这与进程的调度算法有关。稍后会做出回答。
4. 进程的性质
竞争性:进程可以有多个,CPU资源只有少量,甚至一个,所以进程之间具有竞争性。进程需要高效完成任务,更合理竞争相关资源,便有了优先级。
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。
并行:多个进程在多个CPU
下分别,同时进行运行,这称之为并行。
多个CPU就有多个调度队列,每个CPU在一段时间内只能有且只有一个进程被调度。这也就意味着,多个CPU在同一时间内可以有两个及以上的进程同时运行,这就叫做并行。
并发:多个进程在一个CPU下采用进程切换的方式,在一段时间内,让多个进程都得以推进。
一个CPU在一段时间内只能调度一个进程,等该进程的时间片用完之后,进程会被切换,得以让多个进程推进。
三、进程切换
CPU内有许多寄存器,这些寄存器就是一套存储空间,寄存器只有一套,属于CPU本身,但是寄存器内部的数据可以有很多。
进程在被切换时,进程的PCB(里有tss)会保留该进程的上下文数据,以便该进程再度被调度时,可以恢复数据
。
看下面这张图
一个CPU只有一个调度队列。
queue数组的类型其实是struct list_head类型
,为了方便理解进程切换,我们认为它是struct task_struct*类型。其实道理都是一样的。它可以使用struct list_head里面的节点指针对进程进行关联。
这个数组大小为140,下标从0到139,[0,99]我们不用管。[100,139]刚好是40个,而我们的进程范围是[60,99]刚好也是40个,这是巧合吗?当然不是了。60 + 40不就是100吗,CPU调度进程不就是根据进程的优先级进行调度的吗。所以,操作系统根据进程的优先级将进程挂接在对应的下标上。
调度进程的时候,就在这个数组里遍历,从下标100开始,有进程就进行调度,时间片到了之后,调度下一个进程。但是linux并不是这样实现的,它用到了一个bitmap[5]
,为什么是5个呢?一个整型4个Byte,32个bit,5个就有160个bit,刚好能对应queue数组140个下标
。那为什么不是4呢?4个的话才128个,是不够的。5个的话会多出20个,不过无所谓,浪费的很少。
linux是采用位图的方式来对queue数组进行遍历,这样做的目的就是为了提高效率
。我们把这种算法称为大O(1)算法。
比特位的位置:数组中第几个队列
。
比特位的内容:表明该队列是否为空
,0表示空,1表示非空。
那它具体是如何进行调度的呢?
我们可以看到runqueue里面有两个nr_active,bitmap[5],queue[140]。这是为什么呢?
我们把nr_active,bitmap[5],queue[140]概括成
struct prio_array
{
nr_active;
bitmap[5];
queue[140];
}
到时候定义一个大小为2的结构体数组,不就有两个struct prio_array了吗。
同时我们还可以看到有struct prio_array* active,struct prio_array* expired指针。那么这两个指针又是干什么的呢?
active指针指向的队列叫做活跃队列,expired指针指向的队列叫做过期队列
。
CPU调度的时候,直接从active指针找到对应的queue[140],新增进程或者时间片到了的进程,从CPU上剥离下来,被剥离下来的进程,只能重新入队列,入过期队列
。
所以为什么要有nice值呢?如果我们直接修改PRI,那么进程就会被立即迁移到对应的队列上(下标),而有了nice值,就可以在不影响active队列中进程的调度,在expired队列中对进程的优先级做出调整
。
等到CPU调度完所有进程,所有进程都会跑到过期队列中。一旦active没有进程了,这时只需要交换active,expired指针内容就可以了
。
那么,这种算法会存在饥饿问题吗?答案是不会
。因为修改nice值并不会影响active指针指向的队列中进程的优先级,而是在调度完之后,在expired指针指向的队列进行进程的优先级调整。
linux不仅仅在互联网中使用,在工作场景中也会被使用。
它包含两类操作系统:分时操作系统,实时操作系统
。
那么,这两者之间有什么区别呢?
分时操作系统强调公平公正,基于时间片轮转式调度。
实时操作系统强调实时性,来一个任务必须根据优先级去执行优先级更高的任务,必须把它执行完,才能执行下一个任务。
[0,99]是给实时操作系统用的。
所以,进程有基于时间片的公平调度的分时进程,也有基于优先级的实时运行
。
今天的文章分享到此结束,觉得不错的给个一键三连吧。