进程切换
死循环的运行
聊到运行,就不得不谈死循环。众所周知,我们在编译器中运行一个死循环时,比如循环里是一个打印,他会一直打印直到我们将他停止。而任何进程的调度都需要运入cpu,那是不是这个死循环一直在cpu里呢?
当然不是了!要是一个死循环一直占有着cpu,那我们还怎么在电脑上做别的事,不是每台机子都有多个cpu的,而我们能退出这个死循环说明他没有一直占有cpu。
死循环也是个进程,而是进程就有他的内核数据结构(task_struct)也就是PCB,他的这个结构中会有时间片,时间到了就结束该进程.
CPU,寄存器
如图,在CPU中,往往有着多个寄存器,他们负责帮助cpu存储当前进程的数据,cpu通过pcb拿到代码和数据,当进程运行完成或时间片结束时,通过pcb返回临时数据更新原代码和数据。(图中蓝色为虚拟的,黑色为实际)
寄存器实质上就是cpu内部的临时空间,他其中的数据随着当前进程的切换而切换。寄存器 != 寄存器里的数据,他们的关系相当于:
int a;
a = 10;
a是定义的一个int型数据,属于空间,10则是内容,空间是只有一份的,而内容是变化的,多份的。
寄存器就相当于这里的a,而每个进程传进来的数据就相当于这里的10。
如何切换
a.故事
我们先用一个例子来引入切换,比如说大学里的当兵保留学籍这件事上,主要分为以下几个过程:
- 1 .将学籍保留(辅导员通过学校保留你的学籍)
- 2 .当兵ing
- 3 .恢复学籍(辅导员帮你恢复你的学籍到学校里)
实际上,这里若放Linux里,就等同于完成了一次切换。如图:
b.具体
如图:
- cpu内的寄存器只有一份,但上下文可以有多分份,对应不同的进程。
当一个进程未执行完需要切换时,进程会带走并保存自己的上下文数据,如进程A,当他暂时被切下来时,需要进程A带走并保存自己的上下文数据!目的在于,下次回来的时候能恢复,便能按照之前的逻辑继续向后运行。每一个执行过的进程,都有自己的上下文数据。
我们电脑上打开多个软件本质上就是运行多个进程,并且cpu的速度远比我们想象的快,他切换的速度远高于我们能感知的速度。
进程切换的核心就是,保存和恢复当前进程的硬件上下文数据,即CPU内寄存器的内容。
b.1 上下文数据存在哪了呢
我们一般认为数据存放于该进程的task_struct里。当然随着计算机的发展,进程的上下文数据可能越来越大,都放到task_struct里未免有点过于冗余。所以实际上,操作系统内有一块专门的地方叫做TSS(任务状态段),用于存放上下文数据,不过我们在老版本的源码中还能看到:
因此Linux中还特意设置了一个全局指针,struct task_struct * current,专门用来存当前进程的task_struct地址。
我们直接记上下文数据存放于该进程的task_struct里就好。
Linux的真实调度算法:O(1)调度算法(体现优先级)
一个CPU中,一个运行队列,他的结构如图:
运行队列(runqueue)的工作流程和介绍
(1)首先我们看到nr_active,他表示调度时队列里有多少进程,nr>0,再进行后面操作(也就是查bitmap)。
(2)其次,为了体现优先级,最普遍的队列调度肯定无法满足Linux的需求,所以Linux的运行队列里有一个queue[140]。这里存放着140个队列,不过[0,99]我们不考虑,因为[0,99]是实时优先级,他一般是用于工业,制造业的,比如汽车的刹车系统,所以他不能频繁切换(总不能你踩着踩着刹车给你切换到你的车载音乐去了吧owo)。因此我们只用考虑从100到139的了,刚好与我们优先级的取值范围60至99对应上,所以计算一个进程该放入哪个位置,有这么一个公式可以得到存放的下标:
优先级-60+(140-40)
(3)那此时若有多个优先级相同的怎么办呢,会把原来的挤掉吗?
自然是不会的,我们只用一个链表链住,相当于每个节点都挂着一个链表就能解决这个问题。
(4)但当我要运行时,我要遍历每一个节点,这好像这并未满足我们想要的O(1),又该如何解决呢?
bitmap[5]便开始发挥作用了,他作为位图,32*5 = 160,160个比特位,足够包含140,其中用每个比特位0/1,来判断这个节点是否为空,为空便可直接跳过。也就是我们所谓的,挑队列,通过位图。
(5)但这此时若有一个优先级为60的进程,他是一个死循环,虽然有时间片阻止他一直霸占CPU,但他调度完岂不是又链回到queue[100]优先级最高的节点,那这样后面的进程岂不是一直无法被调度,也就是所谓的进程饥饿?
设计师自然也想到了这个问题,于是仔细看图,你会发现在array[0]的下面,还有一个array[1],也就是过期进程。
实际上,他们两个的结构完全相同,我们将正在运行的结构体叫做活跃进程,而另一个叫过期进程,并用一个prio_array[1]数组来存放这两个结构体:
这里用两个结构体来对进程做处理,当一个在CPU运行完的进程,他不会回到活跃进程,而是会插入过期进程。也就是说active queue进程会越来越少,expired queue进程会越来越多。
(6)等到活跃进程把其所有进程都调度完成,执行swap(&active,&expired),让* active指向下一个队列(原过期),* expired指向一个空队列。然后一直重复。
(7)但有朋友可能又会思考,若此时再来一个新进程,他会放在哪里呢?若新进程一直源源不断插入,那我们更早插入,但优先级更低的进程,岂不是一直无法被调用,可能会导致进程饥饿了不是吗?
实际上,我们一般会把他放在过期队列,也就是让他处于就绪状态,这样当该活跃队列执行完毕后才会再轮到他。
不过现在有的操作系统也支持优先级抢占,若新插入的进程也有此特权,也是有可能插入至活跃队列的。
优先级不直接改PRI的原因
现在我们可以再回过头来看看我们上一节优先级留下的问题,为什么PRI和NI,我们要改优先级不直接在PRI上改?
因为更改一个优先级是随时可改的,若改时,该进程在活跃队列,将他链入活跃进程还是过期进程?
- 保留在活跃进程中,他的优先级与他所处的下标不统一。
- 链入到过期进程中,他还没实现就过期了。
两个情况都不太好。所以为了配备调度算法,我们改优先级时,给他一个NI值,会等到该进程更新完毕后,该链入到过期进程中时,再度更新(PRI(80) + NI)。
总结
调度器要想快速挑选一个进程,分为挑队列和挑进程。他通过bitmap挑队列,优先级高低挑进程,大大加快了运行效率。所以在系统当中查找⼀个最合适调度的进程的时间复杂度是⼀个常数,不随着进程增多⽽导致时间成本增加,我们称之为进程调度O(1)算法!