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自旋比较浪费