操作系统之进程状态、优先级和切换与调度
文章目录
- 1. 进程状态
- 1.1 课本名词提炼
- 1.2 运行&阻塞&挂起
- 1.2.1 运行
- 1.2.2 阻塞
- 1.2.3 挂起
- 1.3 理解内核链表
- 1.4 Linux中的内核解释
- 1.5 进程状态的查看
- 1.6 Z(zombie)——僵尸进程
- 1.6.1 创建僵尸进程
- 1.6.2 僵尸进程的危害
- 1.7 孤儿进程
- 2. 进程优先级
- 2.1 基本概念
- 2.2 查看优先级
- 2.3 PRI和NI
- 2.4 手动更改进程的优先级
- 2.5 竞争、独立、并行与并发
- 3. 进程切换与调度
- 3.1 基本概念
- 3.2 Linux中真实的调度算法:O(1)调度算法(重点)
- 3.2.1 初步了解
- 3.2.2 详细介绍
- 3.2.3 总结
1. 进程状态
1.1 课本名词提炼
1.2 运行&阻塞&挂起
1.2.1 运行
我们知道,CPU的资源是有限的,但每个进程都需要CPU来进行处理数据的操作,于是CPU会在其内部维护一个叫做调度队列的数据结构,每个进程按照一定的顺序依次排列在一起,CPU根据先来先服务的算法,依次从头至尾遍历执行这个调度队列中的所有进程,这种调度算法也叫做:FIFO。于是笼统的讲,进程在调度队列中,其状态就是运行状态。更细致的讲,正在被CPU处理的进程叫做正在运行态,在调度队列中等待的进程叫做就绪(等待)运行态。
1.2.2 阻塞
拿上图来说,当1号进程被CPU执行的时候,其内部进行了I/O申请,于是1号进程就会去等待对应的I/O硬件资源设备就绪,此时它会被链接到一个叫做等待队列的数据结构中,此时我们把在等待队列中进程的状态叫做阻塞状态。
1.2.3 挂起
- 所谓挂起,就是当前的系统内存资源非常紧张不足的时候,此时操作系统会把对应一部分进程的代码和数据放入磁盘的交换分区内(swap分区),以此来调解缓和内存资源不足的问题。挂起分为:1. 阻塞挂起 ,2. 就绪挂起。
- 阻塞挂起顾名思义就是把在阻塞状态的进程的代码和数据放入swap分区内,来缓解内存不足问题,就绪挂起是在阻塞挂起后,依然出现内存资源不足,此时会把就绪状态的一部分的进程的代码和数据也给放入swap交换分区内。
- 阻塞挂起
- 就绪挂起
1.3 理解内核链表
进程状态的变化,表现之一,就是在不同的队列之间进行流动,也就是对应数据结构的增删查改。那怎么能做到这种流动的呢?也就是说进程是怎么做到既属于这个数据结构,又属于另外一个数据结构的呢?
这得涉及到具体的操作系统内核中进程的设计,以Linux内核为例,其在进程PCB(task_struct)的设计中,采用了增加 struct list_head* 结构体指针变量的形式,其结构如下所示:
struct list_head {
struct list_head* prev, *next;
};
具体的来说,在 struct task_struct 结构体内部,采用组合的方式,增加了struct list_head* 结构体指针作为成员变量,其内部是两个指针,可以指向前驱与后继进程结点,对应着上述就绪队列就可以取名为,struct list_head* run_queue,同理,等待队列就是,struct list_head* wait_queue。于是,判断一个进程到底属于哪个队列,就可以看其task_struct内对应哪个list_head*的指向不为空即可。这也是一个先描述,再组织的过程,如下图所示:
1.4 Linux中的内核解释
- 为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。
- 体现在具体的代码中,也就是Linux中的task_struct(PCB)中,所谓的进程状态就是task_struct中的一个整型变量。可以对该整型变量进行数值判断,来判断该进程此时位于什么状态。
进程状态在kernel源代码定义如下:
/*
*The task state array is a strange "bitmap" of
*reasons to sleep. Thus "running" is zero, and
*you can test for combinations of others with
*simple bit tests.
*/
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 */
};
- R运行状态(running):并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
- S睡眠(阻塞)状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
- D磁盘休眠状态(disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
- T停止状态(stopped):可以通过发送SIGSTOP信号给进程来停止进程。这个被暂停的进程可以通过发送SIGCONT信号让进程继续运行。
- X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
下一小节是具体的实践操作观察。
1.5 进程状态的查看
具体的进程查看可以使用 ps aux / ps ajx 命令来查看
• a:显示一个终端所有的进程,包括其他用户的进程。
• x:显示没有控制终端的进程,例如后台运行的守护进程。
• j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息。
• u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等。
区别:
ps aux
ps ajx
ps命令查看的时候是静态查看的,也就是说,它查看的当前进程状态的一个快照。为了方便观察与述说,我们可以使用以下脚本命令(grep test是筛选含test的字段进行打印,test是可执行程序名)来实施每隔一秒进行自动监测:
while :; do ps ajx |head -1; ps ajx | grep test; sleep 1; done
查看运行状态——R:
输入以下代码:
#include<stdio.h>
int main()
{
while (1) ;
return 0;
}
查看:
查看阻塞状态——S:
输入以下代码:
#include<stdio.h>
int main()
{
int x = 0;
scanf("%d", &x); // 进行I/O申请,所以是阻塞
return 0;
}
查看:
测试printf:
#include<stdio.h>
int main()
{
while(1)
printf("hello shuaiming\n");
return 0;
}
查看:
分析:对上面情况其实很好理解,申请I/O时,是阻塞状态,当申请I/O完了之后就是运行状态。打个比方,程序每打印一条语句呈现在窗口上,所花费的时间是1秒,这其中I/O操作是占了大头,可能占到了百分之80的比例,其中只有20的比例是在运行程序,而我们设置的脚本命令是每隔1秒监测一次,也就是说,我们的进程是不断的在运行队列和阻塞队列之间来回的进行切换的,即存在一定的运气成分,能让我们监测时同时监测到R和S状态,这与我们的理论并不冲突。
对于其中状态后跟的+号解释,如R+:其实+就是表示它是在前台运行,没有+就是在后台运行,我们可以手动让程序在后台运行,只需运行时最后跟上&即可,如:
./test &
查看:
其余状态:
- T,当程序一直在窗口中跑时,我们输入ctrl+z 就可以停止程序,此时对应的状态就是T状态
- t,程序进行调试时,对应的状态
- D,不可进行杀死的阻塞状态,这个状态是必须会得到某种硬件资源后,方可解除D状态继续运行。其中在等待硬件资源时,是不可被杀死,而S状态在等待硬件资源时,是可以被杀死
- X,是一种返回状态,不能被观察到
- Z,僵尸状态,具体见下小节
1.6 Z(zombie)——僵尸进程
一个人离奇死亡,法医会对他进行抽样检测获取死亡信息,警察会将法医获取的死亡信息告知家属,然后再处理尸体。其中人在死亡,直至法医获取死亡信息的这一时间段内,我们称这人为僵死(僵尸)状态。同理在计算机的世界内,有个神奇的进程,叫作僵尸进程,它是子进程在已经完成所有的任务后,将要退出销毁了,此时它需要将自己完成任务的情况信息返回给父进程,也就是等待着父进程给他“验尸”,在父进程收集完它所有的信息后,子进程才会被完全销毁。在完成所有任务后,等待“验尸”的这一时间过程中,我们称该进程为僵尸进程。 有僵尸进程的概念就是为了获取子进程退出的信息。
1.6.1 创建僵尸进程
如下代码,我们创建一个维持30秒的僵尸进程:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
else if (id > 0) { //parent
printf("parent[%d] is sleeping...\n", getpid());
sleep(30);
}
else {
printf("child[%d] is begin Z...\n", getpid());
sleep(5);
exit(EXIT_SUCCESS);
}
return 0;
}
查看:
1.6.2 僵尸进程的危害
- 如果父进程一直不管,不进行回收,那么Z状态一直存在,这种情况势必会造成内存资源浪费,也就是内存泄漏
- 此外,Z状态也是状态,也需要操作系统去维护,这也会造成时间资源上的浪费
1.7 孤儿进程
当父进程提前退出,但此时子进程却没有退出,此时子进程就成了 “孤儿”,等到子进程退出时,会变成僵尸进程,此时如果没有父进程来“验尸”的话,那么又会造成资源的浪费。为了避免这种情况的发生,Linux中引入了孤儿进程的概念,即父进程提前退出,对应的子进程会被1号进程给领养,同时退出时,会由1号进程来进行回收。 此时被领养后的孤儿进程会由前台进程变成后台进程。
代码样例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
while (1)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
else if (id == 0) {//child
printf("I am child, pid : %d\n", getpid());
sleep(1);
}
else {//parent
printf("I am parent, pid: %d\n", getpid());
sleep(1);
exit(0);
}
}
return 0;
}
查看:
2. 进程优先级
一个计算机系统中的CPU的资源是有限的,而某个时间段要执行很多的进程任务,这些进程任务中,有的很重要,有的又相对不是那么重要,如果不对这些进程任务进行合理的安排去让CPU执行的话,那么那些急需响应的进程任务就不能及时响应,达不到用户所期待的效果,用户的使用感就会下降,于是在具体的Linux操作系统中引入了优先级的概念。
2.1 基本概念
- CPU资源分配的先后顺序,就是指进程的优先级(priority)。
- 优先级高的进程有优先执行权利。配置进程优先级对多任务环境的linux很有用,可以改善系统性能。
- 还可以把进程运行到指定的CPU上,这样⼀来,把不重要的进程安排到某个CPU,可以大大改善系统整体的性能。
注意:优先级与权限是两种概念,优先级是指能得到资源的先后顺序,而权限是指是否能得到该资源。
2.2 查看优先级
输入ps -l 命令:
注意到一些重要信息:
- UID:执行者的身份
- PID:进程的唯一标识符
- PPID:该进程对应父进程的标识符
- PRI:代表这个进程可被执行的优先级,值越小优先级越高
- NI:代表这个进程的nice值,也就是对应优先级的修正值
2.3 PRI和NI
- PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
- 当nice值为负值的时候,那么该程序的优先级值将变小,即其优先级会变高,则其越快被执行,反之相反
- 在Linux下,调整进程优先级,就是调整进程nice值
- Linux操作系统是基于时间片的分时操作系统,为了考虑公平性,优先级可能变化,但变化的幅度不能太大
- nice其取值范围是-20至19,一共40个级别。
需要强调的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。可以理解nice值是进程优先级的修正数据。
2.4 手动更改进程的优先级
使用top命令更改已存在进程的nice值,具体操作如下:
- top
- 进入top后按“r”‒>输入进程PID‒>输入nice值
进入top:
输入r:
输入对应要修改的进程PID按下回车:
最后输入nice修正值:
查看结果:
注意:优先级设置的不合理,会导致优先级低的进程长时间得不到CPU资源,从而造成进程饥饿。
2.5 竞争、独立、并行与并发
- 竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
- 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
- 并行:多个进程在多个CPU下分别运行,即在同一时刻内,是有多个进程在同时执行任务,这便称之为并行
- 并发:多个进程在一个CPU下采用进程切换的方式,即在一个时间段之内,让多个进程都得以推进,称之为并发。从微观时间上来看,依旧是一个进程执行完再执行下一个进程,从宏观时间上来看,则像是多个进程在同时运行一样
3. 进程切换与调度
3.1 基本概念
简单来说:
Linux操作系统是基于时间片的分时操作系统,也就是说一个进程的时间片用完了,它就不会再占用CPU了,那么它就会被CPU给替换出来,让下一个新的进程进入CPU工作,这一过程就叫做进程切换。
这个过程也叫做CPU上下文切换,更加专业详细一点来说:
CPU上下文切换:其实际含义是任务切换,或者CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器, 并开始下一个任务的运行,这一过程就是context switch。
参考一下Linux内核0.11代码:
注意:
时间片:当代计算机都是分时操作系统,它保证了进程运行的公平性,每个进程都有它合适的时间片(其实就是一个计数器)。时间片用完,进程就被操作系统从CPU中剥离下来。 所以一个死循环程序,它并不会一直占用CPU,它的时间片用完了,也自然就会停止运行,只不过我们看它似乎一直在运行的原因就是,时间片设计的时间间隔很小,操作系统进行进程切换的速度又很快,导致人为的认为它一直在运行。
3.2 Linux中真实的调度算法:O(1)调度算法(重点)
3.2.1 初步了解
下图是Linux2.6内核中调度队列的数据结构,先整体认识下之间的关系,后续会有更全面讲解:
3.2.2 详细介绍
一个CPU拥有一个runqueue(就绪队列),runqueue内部含有一个存放两个元素的优先级数组(prio_array_t),这两个元素是一个结构体类型,分别表示活动队列与过期队列,这两个队列的结构是一致的。
更具体的来看:
- active指针永远指向活动队列
- expired指针永远指向过期队列
- nr_active表示当前活动队列中的进程数,在活动队列中,这些进程就是活跃的,是时间片还没有用完,将要准备执行的。过期队列中就是过期的,是当前一个调度内时间片用完了,但本身任务还没有完全执行完的。
- 具体的进程是存放在一个叫做queue的指针数组中的,其数组大小是140,正好一一对应140个优先级,这个指针数组实际上就是一个开散列的哈希表,采用了链地址法的形式,根据每个进程的优先级,对应映射在其对应的数组下标。注意:不是说进程的数量是只有140个,是说优先级位数是140个。也就是说一个数组下标对应一个优先级的进程队列。
- bitmap[5]其实就是另一个数据结构——位图。其内部存在5个32位的int整型变量,5*32=160,也就是说用了160个比特位去对应记录每个优先级的进程队列中是否有进程,比特位数值为1表示当前比特位对应的进程队列有数据,为0则表示没有。于是就可以通过进行一系列位运算,来判断哪个优先级的进程队列中还有进程。然后运用哈希表的哈希函数进行快速索引找到对应不为空的进程队列去拿进程进行切换与调度。注意:不是说有160个比特位就要全部用到,只是用其中的140个即可。
对于140个优先级的进程队列,实质我们关心的只有后40个,也就是对应下标范围[100,139],这个也叫做普通优先级,正好对应了上面所说的nice值的范围[-20,19]。其余100个都叫做实时优先级,实时优先级的优先顺序又是高于普通优先级的,正如它的名字一样,它是实时的,动态的,这个归系统调度管理。
- 普通优先级:100~139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
- 实时优先级:0~99(不关心)
对于为什么要划分实时与普通优先级?我的理解是:
Linux内核设计为了兼顾全面性,在整体基调是分时系统的前提下,内部又引入了实时系统,实时系统是为了应对那些强制性和高响应硬实施的进程任务而设计的,可以在一个调度内动态的插入进程任务于实时优先级的队列中,也就是重要的先来,直接插队。而后40个普通优先级的进程队列则是静态的,基于时间片的公平调度,一个进程时间片完成了,但还有任务没完全完成就会放入过期队列中,同时在一个调度循环内,如果有更高优先级的进程来了,它不会像实时优先级那样直接插队,而是直接按照优先级去入到过期队列中,等待下一个调度循环的到来。
具体如下图:
当然随着时间的推移,进程会根据优先级的顺序挨个执行,然后时间片完了的但还没完全执行完的进程任务就会放入过期队列中,同时在这段执行的时间段内,也会有新的进程加入进来,于是便有了下图:
时间向前流动,那么活动队列中的进程数会越来越少,过期队列中的进程数会越来越多,直至活动队列没有进程数时,也就是nr_active==0时,此时意味着一个调度循环执行完成,需要执行下一个调度循环,也就是要执行过期队列中还没执行完的进程,此时只需交换runqueue中的active与expired指针的指向即可完成过期队列向活动队列的切换,如下图:
思考:为什么非要加上bitmap[5]来进行对应的映射,然后找非0值的比特位进行索引,我的优先级运行队列(queue[140])的长度也是固定的,挨个进行遍历查找也能做到O(1)的时间复杂度?
回答:
即使你的queue的长度时固定,但从局部时间开销来看,每次都去遍历140单位的长度,可能有些下标对应的优先级有很多进程,也可能有些连续很长下标范围内没有进程。从整体来看,我们只是需要去访问那些有进程的下标,那些没有进程的下标我们不用关心。如果挨个遍历的,从局部来看就是个O(N),而且你的前100个长度是动态的,实时的,可能会有新进程进行插队,这样挨个进行遍历的操作就不能很好的处理实时性的要求。注意:我们把位运算看成是原子操作,也就是说,位运算非常的快。
3.2.3 总结
通过active指针永远指向活动队列这一特性,我们只需判断活动队列中的nr_active值是否为0即可,不为0,就去通过对应的bitmap[5](位图)进行一系列位运算操作,得到不为空的对应下标,然后通过哈希函数进行快速索引,进而依次得到优先级从高到低排列的进程,不断的进行切换与调度,直至nr_active的值为0,此时,一个调度循环就算完成了,接着我们再交换active与expired指针的指向,进行下一个循环,周而复始。从时间上来看,每次切换与调度一个进程进出CPU的时间消耗就是O(1)。这就是Linux中的O(1)调度算法。