进程调度演变过程之O(1)与O(n)调度器 - 进程与线程(十五)

1 调度器概述
任务调度器是操作系统中一个很重要的功能部件,主要功能是把系统中的task调度到各个CPU上去执行,满足如下的性能需求:

调度器必须是公平的:(对于分时的进程,每个任务都应该有机会执行,不能饿死,保证每个进程得到合理的CPU时间)
快速的进程响应时间:(对于交互式进程,需要和用户进行交流,因此对调度延迟比较敏感)
高系统的吞吐量:(对于批处理进程进程,属于那种在后台的默默奉献的,因此它更注重吞吐量的需求)
功耗要小:(对于移动式终端,功耗的需求其实一直以来都没有特别被调度器重视,当然在linux大量在手持设备上应用之后,调度器不得不面对这个问题了)
对于操作系统,为了达到这些设计目标,调度器必须要考虑某些调度因素,比如优先级、时间片等。很多操作系统的调度器都是采用优先级,调度器总是选择优先级最高的那个进程执行。而在linux内核中

优先级是实时进程调度的主要考虑因素
对于普通进程,如何细分时间片则是调度器的核心思考点。过大的时间片会一种损伤系统的响应延迟,让用户明显能够感知到延迟、卡顿,从而影响用户体验。较小的时间片虽然有助于减小调度延迟,但是频繁的切换对系统的吞吐量会造成严重的影响。
所以,Linux任务调度器的设计是一个极具挑战的工作,需要在各种有冲突的需求中维持平衡。

2. 经典调度算法
1962年由Corbato等人提出了多级反馈队列(Multi-level Feedback Queue,MLFQ)算法,这对操作系统的进程调度的设计产生了深远的影响。很多操作系统的进程调度器(如Solaris、FreeBSD、Windows NT、linux内核的O(1)调度器等),都是以这个多级反馈队列为基本思想。

多级反馈队列算法的核心思想

把进程按照优先级分成多个队列,相同优先级的进程在同一个队列中

如果进程A的优先级大于进程B的优先级,那么调度器选择进程A
如果进程A和进程B的优先级一样,那么它们同属于一个队列里,使用轮转调度算法来选择
多级反馈队列算法的精髓在于反馈,多级反馈队列算法需要判断进程属于哪种进程,然后做出不同的反馈

当一个新进程进入调度器,把它放入优先级最高的队列里,若这个进程完全占用了CPU,说明这是一个CPU消耗型的进程,那么需要把优先级降一级,将其从高优先级队列中迁移到低一级的队列里
若一个进程在时间片没有结束之前放弃CPU,那么说明这是一个I/O消耗型的进程,那么优先级保持不变
这个反馈算法看起来不错,可是在实际应用过程中还是发现它有不少问题。

第一个问题,会产生“饥饿”,当系统中有大量的I/O消耗型的进程的时候,这些I/O消耗型的进程会把CPU完全占满,因为他们的优先级最高,所以那些CPU消耗的进程就会得不到CPU时间片,从而产生饥饿
第二个问题,有些进程会欺骗调度器,有的进程在时间片快要结束的时候突然发起了一个I/O请求并放弃CPU,按照规则,调度器把这个进程判定为I/O消耗型的进程,从而欺骗调度器,它继续保留在高优先级的队列里面。这种进程其实99.99%的时间在占用CPU时间片,到了最后时刻还巧妙的利用规则来欺骗调度器。如果系统中有大量的这种进程,那么系统的交互性就变差了
第三个问题,一个进程很难去判断究竟属于I/O消耗型还是CPU消耗型,一个进程可能一会儿是I/O消耗型,一会儿是CPU消耗型
3. linux调度器演变
3.1 早期版本
Linux0.11版本就已经有一个简单的调度器,当然并不适合现在的多处理器的系统。该调度器值维护了一个全局的进程队列,每次都需要遍历该队列来寻找新的进程执行,具体请参考进程管理(三)----linux进程管理起源

在这里插入图片描述

3.2 linux内核的O(n)调度算法
linux2.4版本的linux内核使用的调度算法也非常简单和直接,由于每次在寻找下一个任务时,需要遍历系统中所有的任务链表,所以被称为O(n)调度器。首先我们来分析核心代码

在这里插入图片描述

在这里插入图片描述

 在这里插入图片描述

对于linux2.4的内核,其实本质跟linux0.11基本类似,只是在用goodness函数会计算一个权重值,它的基本思想是基于进程所剩余的时间片,在加上进程优先级的权重。在计算动态优先级的时候,对进程分为两种情况,实时进程和普通进程,对于实时进程而言,动态优先级等于静态优先级加上一个固定的偏移

在这里插入图片描述
而对于普通进程而言,动态优先级的计算就稍微比较复杂,其计算动态优先级的策略如下

在这里插入图片描述
总体来说上面的实现,很任意理解

jiffies是系统开机以来tick的次数(alarm>jiffies说明过期了,重置为0) -
counter是时间片,单位是tick(时钟滴答),调度器根据couter大小决定优先级(couter越大优先级越高)
NR_TASKS是task(进程)总数。
其流程为:
1 第一次循环是检查一遍alarm()函数,唤醒任何收到alarm传来的signal的没有被阻塞的tasks,将TASK_INTERRUPTIBLE(挂起)改为TASK_RUNNING可执行。
2 while(1) 这个死循环一直执行到关机,每次循环先while (–i)找出counter(时间片)最大的task。
3 if © break;和下面的这些是说如果c为0(所有进程的counter用完了),就重新分配counter。
4 最后调用switch_to(next)切换进程。(切换到counter最大的一个)

nice成员就是普通进程的静态优先级,之所以用(20-nice value)表示静态优先级,主要是为了使得静态优先级变得单调上升。Linux为了奖励睡眠的进程,所以睡眠的进程剩余的时间片会较多,因此动态优先级页会高一些,下一次会更容易得到调度执行。

调度器是根据动态优先级来进行调度,谁大就执行谁,我们以普通进程为例

如果进程静态优先级较高,即nice值较小,剩余的时间多,那么必定是优先执行
如果进程静态优先级较高,即nice值较小,但是所剩余时间片无几,那么可能会让位给剩余时间片较多
所以对于这个权重值,理解如下:

-1000,表示不要选择该进程运行
0,表示时间片用完了,需要重新计算counter
正整数,goodness值越大越容易执行
+1000,表示实时进程,接下来就要选择它运行
3.3 O(n)调度时机
对于2.4的内核,产生调度的时机主要包括以下几个方面

1.进程主动发起调度
2.在timer中断处理中发现当前时间片耗尽
3.进程唤醒的时候
4.父进程在fork的时候,其时间片会均分到父子进程,但是如果只剩下一个tick,这个tick会分配给子进程,而父进程的时间片会被清0,这个时候父进程执行就等同于在timer中断处理中发现当前时间片耗尽;如果父进程在fork的时候,时间片较大,父子进程的时间片都不为0,这个时候的场景类似于唤醒进程,向runqueue中添加一个task,从而引发调度
5.用户进程主动让出CPU的时候
6.用户进程修改调度参数的时候
以上的场景中,除了进程主动调度之外,其他的场景都不是立刻调度schedule函数,而是设定need_resched标记,然后等待调度点的到来。而对于2.4的内核,由于不是抢占式,因此调度点总是在返回用户空间的时候才会到来,当调度点到来的时候,进程调度会在该CPU上启动。

3.4 O(n)调度时间片处理
对于普通进程的时间片处理思路是

每个进程根据其静态优先级可以固定分配一个缺省的时间片,静态优先级越大,分配的时间片越大
一旦Runqueue的进程被调度执行,那么其时间片会在tick到来的时候递减,如果进程时间片耗尽,那么该进程将失去分配CPU资源的资格
Runqueue中的进程的时间片全部被用完之后,也就是调度周期结束,这个时候需要为runqueue中的进程重新设定其缺省的时间片,这样,新的调度周期又开始了
当runqueue中所有进程的时间片耗尽后,这个时候开启一次重新加载进程缺省时间片的过程,如下:

在这里插入图片描述

这里的C是遍历runqueue链表之后找到最大的动态优先级,如果它为0,则说明

系统中没有处于可以运行状态的实时进程,
所有的可运行状态的普通进程都已经耗尽完了它们的时间片,这个时候就需要重新计算
通过for_each_task遍历系统中的所有进程描述符,不论是否是可运行状态。

对于挂入runqueue链表中的普通进程而言,其当前的p->counter已经是0,因此它获得新的时间片就是nice计算得到的缺省时间片
对于挂人等待队列中处于睡眠状态进程而言,其时间片为p->counter还有剩余,会累积到进程时间片配额中,也算是对睡眠进程的一种奖励,也就是对于内核会奖励睡眠进程。
同时为了防止经常睡眠的交互式进程获得过于庞大的时间片,并不是累积其全部存留的时间片,而是打个对折
新的一个周期开始,消耗的时间片通过timer中断处理,其流程如下:

在这里插入图片描述

每一个tick中断到来的时候,进程的时间片都会减1,当时间片是0的时候,调度器剥夺其执行的权利,从而引发一次调度,选择其他时间片不为0的进程,直到runqueue中所有进程的时间片耗尽,又开始新一轮周期。调度器就是这样周而复始,推动整个系统的运作。

4. 总结
O(n)调度器是采用基于优先级的一种调度算法,Linux2.4内核以及更早都是采用这种算法,其定义如下:

就绪队列是一个全局链表,从就绪队列中查找下一个最佳的就绪进程,需要遍历整个就绪队列,花费的时间与就绪队列的进程数量有关,所耗费的时间是O(n),所以该调度器被称为O(n)调度器

在这里插入图片描述

在这里插入图片描述
对于O(n)调度器:

调度器基于优先级设计,每个进程在创建时被赋予一个固定的时间片,当前进程时间片用完后,调度器会选择下一个进程来运行
选择next算法非常简单,对于runqueue中所有的进程进行依次比较,选择优先级最高的进程作为下一个被调度的进程
每次进程切换的时,内核扫面可运行的进程链表,计算优先级,然后选择最佳进程来运行
所有进程的时间片都用完后,才会为所有进程重新分配时间片
但是o(n)调度器页面临很多问题:

时间复杂度问题,时间复杂度为O(n),当系统中进程很少的时候,性能还可以,但是当系统进程增加后,选择下一个进程会变得越来越慢,从而导致系统整体性能下降;同时当系统中无可运行进程时,重新初始化进程的时间片也是相当的耗时
实时进程的运行效率问题,因为实时进程和普通进程在一个列表中,每次查实时进程时,都需要全部扫描整个列表,导致实时进程不是很“实时”
因为系统中只有一个runqueue,则当运行队列中的进程少于CPU的个数时,其余的CPU则几乎是idle状态,浪费资源
cache缓存问题:当系统中的进程逐渐减少时,原先在CPU1上运行的进程,不得不在CPU2上运行,导致在CPU2上运行时,cacheline则几乎是空白的,影响效率。
SMP扩展问题。当需要picknext下一个进程时,需要对整个runqueue队列进行加锁的操作,spin_lock_irq(&runqueue_lock);当系统中进程数目比较多的时候,则在临界区的时间就比较长,导致其余的CPU自旋比较浪费
 

 上一章学习了O(n)调度器的设计,以及它的核心算法,其主要思路如下:

  • O(n)调度器采用一个Runqueue运行队列来管理所有可运行的进程,在主调度schedule函数中选择一个优先级最高,也就是时间片最大的进程来运行,同时也会对喜欢睡眠的进程做奖励,去增加此类进程的时间片
  • 当Runqueue运行队列中无进程可选择时,则会对系统中所有的进程进行依次重新计算时间片的操作

针对这个调度,虽然简单,但是也存在去缺点

  • 时间复杂度为O(n),当系统中就绪队列中的进程数目增多,那么调度器的运算量就会线性增长,为每个进程计算其时间片的过程太耗费时间
  • 多核处理器扩展问题,多处理器的进程在同一个就绪队列中,因此调度器对它的所有操作都会因为全局自旋锁而导致系统各个处理器之间的等待,使的就绪队列称为明显的瓶颈
  • 实时进程不能及时调度,内核不可抢占,如果某个进程,一旦进入内核态,那么再高优先级的进程都无法剥夺,只有等进程返回用户态的时候才可以被调度

针对以上问题,Linux2.6做了较大的改进,针对多处理器问题,为每个CPU设置一个就绪队列,实现了时间复杂度为O(1)的调度算法,本章重点学习以下内容:

  • O(1)调度器是什么?
  • O(1)调度算法如何实现?

1. O(1)调度算法介绍

Ingo Molnar在linux2.6版本的内核中加入了全新的调度算法,它能够在常数时间内调度任务,因此被称为O(1)调度器,它主要引入了一些新的特性:

  • 全局优先级,范围为0~139,数值越低,优先级越高
  • 将进程拆分成实时进程(0 ~ 99)和普通进程(100 ~ 139),更高优先级任务获得更多的时间片
  • 支持抢占,当任务状态变成TASK_RUNNING时,内核会检查其优先级是否比当前任务的优先级更高,如果是的话,则抢占当前正在运行的任务,切换到该任务
  • 实时进程使用静态优先级
  • 普通进程使用动态优先级,任务优先级会在其使用完自己的时间片后重新计算,内核会考虑它过去的行为,决定它的交互等级,交互型任务更容易得到调度

对于O(n)调度器会在所有进程的时间片用完后,才会重新计算任务的优先级。而O(1)调度器则是在每个进程时间片用完后,就重新计算优先级。对于O(1)调度器为每个CPU维护了两个队列

  • **active队列:**存放的是时间片尚未用完的任务
  • **expired队列:**存放的是时间片已经耗尽的任务

当一个队列的时间片用完后,就会被转到expired队列,而且会重新计算它的优先级,当active队列任务全部转移到expired队列后,会交换二者,使得active队列指向expired队列,expired队列指向active队列。可以看到,优先级的计算,队列切换都和任务数量多寡无关,能够在O(1)的时间复杂度下完成。其基本的思路如下图所示

在这里插入图片描述

当 active 中的任务时间片用完,那么就会被移动到 expired 中。
当 active 中已经没有任务可以运行,就把 expired 与 active 交换,从而 expired 中的任务可以重新被调度。
2. O(1)调度算法数据结构
为了减小多核CPU之间的竞争,所以每个CPU都需要维护一份本地的优先级队列,因为如果使用全局的优先级,那么多核CPU就需要对全局优先队列进行上锁,从而导致性能下降。runqueue结构主要维护调度相关的信息,其定义如下

在这里插入图片描述
接下来,我们看看prio_array_t

 在这里插入图片描述

nr_active:所有优先级队列中的数任务数
bitmap:位图,每个位对应一个优先级的任务队列,用于记录哪个任务队列不为空,能通过Bitmap快速找到不为空的任务队列
queue:优先级队列数组,每个元素维护一个优先级队列,比如索引为0的元素维护着优先级为0的任务队列
对于以上的,每个优先级是一个链表,同时还维护了一个由101 bit组成的Bitmap,其中实时进程的优先级是0~00,占100bit,当某个优先级上有进程被插入链表时,响应的比特位就被置位。在进度算法中通常用sched_find_first_bit来查询该bitmap,它返回当前被置位的最高优先级的数组下表,由于使用位图,查找一个任务来执行所需的时间并不依赖于任务的个数,而是依赖于优先级的数量,所以该调度器是一个O(1)调度器

在这里插入图片描述

bitmap 的第2位和第6位为1(红色代表为1,白色代表为0),表示优先级为2和6的任务队列不为空,也就是说 queue 数组的第2个元素和第6个元素的队列不为空。

3. 实时进程和普通进程
O(1)调度算法 把140个优先级的前100个(0 ~ 99)作为 实时进程优先级,而后40个(100 ~ 139)作为 普通进程优先级。实时进程被放置到实时进程优先级的队列中,而普通进程放置到普通进程优先级的队列中。

实时进程

实时进程分为FIFO(先进先出)和RR(时间片轮转)两种算法,其调度算法比较简单,如下:

**先进先出实时进程调度:**如果调度器在执行某个先进先出的实时进程,那么调度器就会一直运行这个进程,直到其主动放弃运行权(退出进程或者sleep等)
**时间片轮转实时进程调度:**如果调度器在执行某个时间片轮询的实时进程,那么调度器会判断当前进程的时间片是否用完,如果用完的话,那么重新分配时间片给它,并且重新放置会active队列中,然后调度其他同优先级或者优先级跟高的实时进程
普通进程

每个进程都有一个动态优先级和静态优先级,静态优先级不会变化,进程创建时被设置;而动态优先级会随着进程的睡眠时间而发生变化。

4. 调度算法实现
与之前的内核一样,内核会设置一个时钟tick,在时钟tick中会进入时钟中断,最终会触发调用scheduler_tick函数

在这里插入图片描述
由于前面的理论知识,那么我们可以猜想在里面可能会做如下的工作

如果时间片用完,那么把进程从active队列移动到expired队列中
如果当前 runqueue 的 active 队列为空,那么把 active 队列与 expired 队列进行交换
对于实时进程和普通进程优先级的处理
重点看一下scheduler_tick的实现

在这里插入图片描述

 在这里插入图片描述在这里插入图片描述

重点的工作来了

在这里插入图片描述

该函数后面的就开始做进程切换了,不在本文的考虑范围之内,我们重点来看看O(1)的核心算法,我们可以直接看

在这里插入图片描述

这三行就是算法的核心,首先去从runqueue的active队列中的bitmap找到一个下标,这个下标就是对应的优先级,然后获取到对应优先级的链表,然后从中获取一个next进程。后面的操作就是执行进程切换,调度了。

在这里插入图片描述

当系统中无可运行进程时,也就是进程的时间片都耗光了,则需要重新给进程设置时间片,只需要切换active和expried的指针即可

5. 静态优先级和动态优先级
进程的优先级分为静态优先级和动态优先级。普通优先级是进程创建时默认设置的优先级,动态优先级会在进程运行时经过动态的调整。

对于O(1)的静态优先级,其定义如下:

实时进程保存在进程描述符rt_priority成员中,取值范围是1(优先级最低) ~ 99(优先级最高)
普通进程保存在static_pro成员中,取值范围是100(优先级最高) ~ 139(优先级最低),分别对应ncice值为-20~19
O(1)调度器中所有进程的动态优先级为p->prio,

在这里插入图片描述

在系统运行中,会通过此函数来重新计算进程的动态优先级,实时进程值需要返回对应的p->prio。而普通进程则需要进行赏罚分明,通过进程的睡眠时间sleep_avg来计算进程是否需要赏罚。当一个进程经常睡眠,则会增加它的优先级,当一个进程常占用CPU,则需要惩罚,降低其优先级。

6. 总结
O(1)调度器的引入主要是为了解决O(n)调度器的不足,O(1)调度器比O(n)调度器考虑的因素更多,更复杂,不像O(n)调度器那样直接考虑时间片的大小来调度,同时也有共同点,那就是爱睡眠进程增大优先级,增大时间片的机制来获取更多的运行时间。

在这里插入图片描述

 在这里插入图片描述

O(1)调度器,为了减小锁的竞争,每个CPU维护一个自己的就绪队列。就绪队列由两个优先级组成,分别是active优先级数组和expired优先级数组,每个优先级数组包含140个优先级,就是每个优先级对应一个队列,其中前100个对应于实时进程,后40个对应普通进程。

但是单论效率,似乎已经没有能够超过O(1)的了,不过O(1)调度器在根据"nice"值确定时间片的算法上,存在一些瑕疵。它所使用的的规则大致是这样的:"nice"为0的任务可以运行100ms,"nice"值每增加1,可运行时间将减少5ms,照此推算,"nice"为+19的任务可以运行5ms。

如果一个任务"nice"是0,另一个是1,那么可运行时间分别是100ms和95ms,差别不大,但如果一个是18,另一个是19,那么可运行时间分别是10ms和5ms,差了一倍。此外,前一种场景的任务切换每105ms发生一次,而后一种场景则是每15ms一次,调度周期的长度并不固定。内核演变就出现了完全公平的调度算法,后面继续学习中…

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值