带你探索CPU调度的奥秘

摘要:本文将会从最基础的调度算法提及,逐个分析各类主流调度算法的原理,带你们一块儿探索CPU调度的奥秘。

本文分享自华为云社区《探索CPU的调度原理》,做者:元闰子。算法

前言

软件工程师们总习惯把OS(Operating System,操做系统)当成是一个很是值得信赖的管家,咱们只管把程序托管到OS上运行,却不多深刻了解操做系统的运行原理。确实,OS做为一个通用的软件系统,在大多数的场景下都表现得足够的优秀。但仍会有一些特殊的场景,须要咱们对OS进行各项调优,才能让业务系统更高效地完成任务。这就要求咱们必须深刻了解OS的原理,不只仅只会使唤这个管家,还能懂得如何让管家作得更好缓存

OS是一个很是庞大的软件系统,本文主要探索其中的冰山一角:CPU的调度原理数据结构

提及CPU的调度原理,不少人的第一反应是基于时间片的调度,也即每一个进程都有占用CPU运行的时间片,时间片用完以后,就让出CPU给其余进程。至于OS是如何判断一个时间片是否用完的、如何切换到另外一个进程等等更深层的原理,了解的人彷佛并很少。学习

其实,基于时间片的调度只是众多CPU的调度算法的一类,本文将会从最基础的调度算法提及,逐个分析各类主流调度算法的原理,带你们一块儿探索CPU调度的奥秘。优化

CPU的上下文切换

在探索CPU调度原理以前,咱们先了解一下CPU的上下文切换,它是CPU调度的基础。url

现在的OS几乎都支持"同时"运行远大于CPU数量的任务,OS会将CPU轮流分配给它们使用。这就要求OS必须知道从哪里加载任务,以及加载后从哪里开始运行,而这些信息都保存在CPU的寄存器中,其中即将执行的下一条指令的地址被保存在程序计数器(PC)这一特殊寄存器上。咱们将寄存器的这些信息称为CPU的上下文,也叫硬件上下文spa

OS在切换运行任务时,将上一任务的上下文保存下来,并将即将运行的任务的上下文加载到CPU寄存器上的这一动做,被称为CPU上下文切换操作系统

CPU上下文属于进程上下文的一部分,咱们常说的进程上下文由以下两部分组成:.net

  • 用户级上下文:包含进程的运行时堆栈、数据块、代码块等信息。
  • 系统级上下文:包含进程标识信息、进程现场信息(CPU上下文)、进程控制信息等信息。

这涉及到两个问题:(1)上一任务的CPU上下文如何保存下来?(2)何时执行上下文切换?3d

问题1: 上一任务的CPU上下文如何保存下来?

CPU上下文会被保存在进程的内核空间(kernel space)上。OS在给每一个进程分配虚拟内存空间时,会分配一个内核空间,这部份内存只能由内核代码访问。OS在切换CPU上下文前,会先将当前CPU的通用寄存器、PC等进程现场信息保存在进程的内核空间上,待下次切换时,再取出从新装载到CPU上,以恢复任务的运行。

问题2: 何时执行上下文切换

OS要想进行任务上下文切换,必须占用CPU来执行切换逻辑。然而,用户程序运行的过程当中,CPU已经被用户程序所占用,也即OS在此刻并未处于运行状态,天然也没法执行上下文切换。针对该问题,有两种解决策略,协做式策略与抢占式策略。

协做式策略依赖用户程序主动让出CPU,好比执行系统调用(System Call)或者出现除零等异常。但该策略并不靠谱,若是用户程序没有主动让出CPU,甚至是恶意死循环,那么该程序将会一直占用CPU,惟一的恢复手段就是重启系统了。

抢占式策略则依赖硬件的定时中断机制(Timer Interrupt),OS会在初始化时向硬件注册中断处理回调(Interrupt Handler)。当硬件产生中断时,硬件会将CPU的处理权交给来OS,OS就能够在中断回调上实现CPU上下文的切换。

调度的衡量指标

对于一种CPU调度算法的好坏,通常都经过以下两个指标来进行衡量:

  • 周转时间(turnaround time),指从任务到达至任务完成之间的时间,即T_{turnaround}=T_{completiong}-T_{arrival}Tturnaround​=Tcompletiong​−Tarrival
  • 响应时间(response time),指从任务到达至任务首次被调度的时间,即T_{response}=T_{firstrun}-T_{arrival}Tresponse​=Tfirstrun​−Tarrival

两个指标从某种程度上是对立的,要求高的平均周转时间,必然会下降平均响应时间。具体追求哪一种指标与任务类型有关,好比程序编译类的任务,要求周转时间要小,尽量快的完成编译;用户交互类的任务,则要求响应时间要小,避免影响用户体验。

工做负载假设

OS上的工做负载(也即各种任务运行的情况)老是变幻无穷的,为了更好的理解各种CPU调度算法原理,咱们先对工做负载进行来以下几种假设:

  • 假设1:全部任务都运行时长都相同。
  • 假设2:全部任务的开始时间都是相同的
  • 假设3:一旦任务开始,就会一直运行,直至任务完成。
  • 假设4:全部任务只使用CPU资源(好比不产生I/O操做)。
  • 假设5:预先知道全部任务的运行时长。

准备工做已经作好,下面咱们开始进入CPU调度算法的奇妙世界。

FIFO:先进先出

FIFO(First In First Out,先进先出)调度算法以原理简单,容易实现著称,它先调度首先到达的任务直至结束,而后再调度下一个任务,以此类推。若是有多个任务同时到达,则随机选一个。

在咱们假设的工做负载情况下,FIFO效率良好。好比有A、B、C三个任务知足上述全部负载假设,每一个任务运行时长为10s,在t=0时刻到达,那么任务调度状况是这样的:

根据FIFO的调度原理,A、B、C分别在十、20、30时刻完成任务,平均周转时间为20s( \frac {10+20+30}{3}310+20+30​),效果很好。

然而现实老是残酷的,若是假设1被打破,好比A的运行时间变成100s,B和C的仍是10s,那么调度状况是这样的:

根据FIFO的调度原理,因为A的运行时间过长,B和C长时间得不到调度,致使平均周转时间恶化为110( \frac {100+110+120}{3}3100+110+120​)。

所以,FIFO调度策略在任务运行时间差别较大的场景下,容易出现任务饿死的问题

针对这个问题,若是运行时间较短的B和C先被调度,问题就能够解决了,这正是SJF调度算法的思想。

SJF:最短任务优先

SJF(Shortest Job First,最短任务优先)从相同到达时间的多个任务中选取运行时长最短的一个任务进行调度,接着再调度第二短的任务,以此类推

针对上一节的工做负载,使用SJF进行调度的状况以下,周转时间变成了50s( \frac {10+20+120}{3}310+20+120​),相比FIFO的110s,有了2倍多的提高。

让咱们继续打破假设2,A在t=0时刻,B和C则在t=10时刻到达,那么调度状况会变成这样:

由于任务B和C比A后到,它们不得不一直等待A运行结束后才有机会调度,即便A须要长时间运行。周转时间恶化为103.33s(\frac {100+(110-10)+(120-10)}{3}3100+(110−10)+(120−10)​),再次出现任务饿死的问题!

STCF:最短期完成优先

为了解决SJF的任务饿死问题,咱们须要打破假设3,也即任务在运行过程当中是容许被打断的。若是B和C在到达时就当即被调度,问题就解决了。这属于抢占式调度,原理就是CPU上下文切换一节提到的,在中判定时器到达以后,OS完成任务A和B的上下文切换。

咱们在协做式调度的SJF算法的基础上,加上抢占式调度算法,就演变成了STCF算法(Shortest Time-to-Completion First,最短期完成优先),调度原理是当运行时长较短的任务到达时,中断当前的任务,优先调度运行时长较短的任务

使用STCF算法对该工做负载进行调度的状况以下,周转时间优化为50s(\frac {120+(20-10)+(30-10)}{3}3120+(20−10)+(30−10)​),再次解决了任务饿死问题!

到目前为止,咱们只关心了周转时间这一衡量指标,那么FIFO、SJF和STCF调度算法的响应时间又是多长呢?

不妨假设A、B、C三个任务都在t=0时刻到达,运行时长都是5s,那么这三个算法的调度状况以下,平均响应时长为5s(\frac {0+(5-0)+(10-0)}{3}30+(5−0)+(10−0)​):

更糟糕的是,随着任务运行时长的增加,平均响应时长也随之增加,这对于交互类任务来讲将会是灾难性的,严重影响用户体验。该问题的根源在于,当任务都同时到达且运行时长相同时,最后一个任务必须等待其余任务所有完成以后才开始调度。

为了优化响应时间,咱们熟悉的基于时间片的调度出现了。

RR:基于时间片的轮询调度

RR(Round Robin,轮训)算法给每一个任务分配一个时间片,当任务的时间片用完以后,调度器会中断当前任务,切换到下一个任务,以此类推

须要注意的是,时间片的长度设置必须是中判定时器的整数倍,好比中判定时器时长为2ms,那么任务的时间片能够设置为2ms、4ms、6ms … 不然即便任务的时间片用完以后,定时中断没发生,OS也没法切换任务。

如今,使用RR进行调度,给A、B、C分配一个1s的时间片,那么调度状况以下,平均响应时长为1s(\frac {0+(1-0)+(2-0)}{3}30+(1−0)+(2−0)​):

从RR的调度原理能够发现,把时间片设置得越小,平均响应时间也越小。但随着时间片的变小,任务切换的次数也随之上升,也就是上下文切换的消耗会变大。所以,时间片大小的设置是一个trade-off的过程,不能一味追求响应时间而忽略CPU上下文切换带来的消耗。

CPU上下文切换的消耗,不仅是保存和恢复寄存器所带来的消耗。程序在运行过程当中,会逐渐在CPU各级缓存、TLB、分支预测器等硬件上创建属于本身的缓存数据。当任务被切换后,就意味着又得重来一遍缓存预热,这会带来巨大的消耗。

另外,RR调度算法的周转时间为14s(\frac {(13-0)+(14-0)+(15-0)}{3}3(13−0)+(14−0)+(15−0)​),相比于FIFO、SJF和STCF的10s(\frac {(5-0)+(10-0)+(15-0)}{3}3(5−0)+(10−0)+(15−0)​)差了很多。这也验证了以前所说的,周转时间和响应时间在某种程度上是对立的,若是想要优化周转时间,建议使用SJF和STCF;若是想要优化响应时间,则建议使用RR。

I/O操做对调度的影响

到目前为止,咱们并未考虑任何的I/O操做。咱们知道,当触发I/O操做时,进程并不会占用CPU,而是阻塞等待I/O操做的完成。如今让咱们打破假设4,考虑任务A和B都在t=0时刻到达,运行时长都是50ms,但A每隔10ms执行一次阻塞10ms的I/O操做,而B没有I/O。

若是使用STCF进行调度,调度的状况是这样的:

从上图看出,任务A和B的调度总时长达到了140ms,比实际A和B运行时长总和100ms要大。并且A阻塞在I/O操做期间,调度器并无切换到B,致使了CPU的空转!

要解决该问题,只需使用RR的调度算法,给任务A和B分配10ms的时间片,这样当A阻塞在I/O操做时,就能够调度B,而B用完时间片后,刚好A也从I/O阻塞中返回,以此类推,调度总时长优化至100ms。

该调度方案是创建在假设5之上的,也即要求调度器预先知道A和B的运行时长、I/O操做时间长等信息,才能如此充分地利用CPU。然而,实际的状况远比这复杂,I/O阻塞时长不会每次都同样,调度器也没法准确知道A和B的运行信息。当假设5也被打破时,调度器又该如何实现才能最大程度保证CPU利用率,以及调度的合理性呢?

接下来,咱们将介绍一个可以在全部工做负载假设被打破的状况下依然表现良好,被许多现代操做系统采用的CPU调度算法,MLFQ。

MLFQ:多级反馈队列

MLFQ(Multi-Level Feedback Queue,多级反馈队列)调度算法的目标以下:

  1. 优化周转时间。
  2. 下降交互类任务的响应时间,提高用户体验。

从前面分析咱们知道,要优化周转时间,能够优先调度运行时长短的任务(像SJF和STCF的作法);要优化响应时间,则采用相似RR的基于时间片的调度。然而,这两个目标看起来是矛盾的,要下降响应时间,必然会增长周转时间。

那么对MLFQ来讲,就须要解决以下两个问题:

  1. 在不预先清楚任务的运行信息(包括运行时长、I/O操做等)的前提下,如何权衡周转时间和响应时间?
  2. 如何从历史调度中学习,以便将来作出更好的决策?

划分任务的优先级

MLFQ与前文介绍的几种调度算法最显著的特色就是新增了优先级队列存放不一样优先级的任务,并定下了以下两个规则:

  • 规则1:若是Priority(A) > Priority(B),则调度A
  • 规则2:若是Priority(A) = Priority(B),则按照RR算法调度A和B

优先级的变化

MLFQ必须考虑改变任务的优先级,不然根据 规则1  规则2 ,对于上图中的任务C,在A和B运行结束以前,C都不会得到运行的机会,致使C的响应时间很长。所以,能够定下了以下几个优先级变化规则:

  • 规则3:当一个新的任务到达时,将它放到最高优先级队列中
  • 规则4a:若是任务A运行了一个时间片都没有主动让出CPU(好比I/O操做),则优先级下降一级
  • 规则4b:若是任务A在时间片用完以前,有主动让出CPU,则优先级保持不变

规则3主要考虑到让新加入的任务都能获得调度机会,避免出现任务饿死的问题

规则4a和4b主要考虑到,交互类任务大都是short-running的,而且会频繁让出CPU,所以为了保证响应时间,须要保持现有的优先级;而CPU密集型任务,每每不会太关注响应时间,所以能够下降优先级。

按照上述规则,当一个long-running任务A到达时,调度状况是这样的:

若是在任务A运行到t=100时,short-time任务B到达,调度状况是这样的:

从上述调度状况能够看出,MLFQ具有了STCF的优势,便可以优先完成short-running任务的调度,缩短了周转时间。

若是任务A运行到t=100时,交互类任务C到达,那么调度状况是这样的:

MLFQ会在任务处于阻塞时按照优先级选择其余任务运行,避免CPU空转。所以,在上图中,当任务C处于I/O阻塞状态时,任务A获得了运行时间片,当任务C从I/O阻塞上返回时,A再次挂起,以此类推。另外,由于任务C在时间片以内出现主动让出CPU的行为,C的优先级一直保持不变,这对于交互类任务而言,有效提高了用户体验。

CPU密集型任务饿死问题

到目前为止,MLFQ彷佛可以同时兼顾周转时间,以及交互类任务的响应时间,它真的完美了吗?

考虑以下场景,任务A运行到t=100时,交互类任务C和D同时到达,那么调度状况会是这样的:

因而可知,若是当前系统上存在不少交互类任务时,CPU密集型任务将会存在饿死的可能!

为了解决该问题,能够设立了以下规则:

  • 规则5:系统运行S时长以后,将全部任务放到最高优先级队列上(Priority Boost

加上该规则以后,假设设置S为50ms,那么调度状况是这样的,饿死问题获得解决!

恶意任务问题

考虑以下一个恶意任务E,为了长时间占用CPU,任务E在时间片还剩1%时故意执行I/O操做,并很快返回。根据规则4b,E将会维持在原来的最高优先级队列上,所以下次调度时仍然得到调度优先权:

为了解决该问题,咱们须要将规则4调整为以下规则:

  • 规则4:给每一个优先级分配一个时间片,当任务用完该优先级的时间片后,优先级降一级

应用新的规则4后,相同的工做负载,调度状况变成了以下所述,再也不出现恶意任务E占用大量CPU的问题。

到目前为止,MLFQ的基本原理已经介绍完,最后,咱们总结下MLFQ最关键的5项规则:

  • 规则1:若是Priority(A) > Priority(B),则调度A
  • 规则2:若是Priority(A) = Priority(B),则按照RR算法调度A和B
  • 规则3:当一个新的任务到达时,将它放到最高优先级队列中
  • 规则4:给每一个优先级分配一个时间片,当任务用完该优先级的时间片后,优先级降一级
  • 规则5:系统运行S时长以后,将全部任务放到最高优先级队列上(Priority Boost

如今,再回到本节开始时提出的两个问题:

一、在不预先清楚任务的运行信息(包括运行时长、I/O操做等)的前提下,MLFQ如何权衡周转时间和响应时间

在预先不清楚任务究竟是long-running或short-running的状况下,MLFQ会先假设任务属于shrot-running任务,若是假设正确,任务就会很快完成,周转时间和响应时间都获得优化;即便假设错误,任务的优先级也能逐渐下降,把更多的调度机会让给其余short-running任务。

二、MLFQ如何从历史调度中学习,以便将来作出更好的决策

MLFQ主要根据任务是否有主动让出CPU的行为来判断其是不是交互类任务,若是是,则维持在当前的优先级,保证该任务的调度优先权,提高交互类任务的响应性。

固然,MLFQ并不是完美的调度算法,它也存在着各类问题,其中最让人困扰的就是MLFQ各项参数的设定,好比优先级队列的数量,时间片的长度、Priority Boost的间隔等。这些参数并无完美的参考值,只能根据不一样的工做负载来进行设置。

好比,咱们能够将低优先级队列上任务的时间片设置长一些,由于低优先级的任务每每是CPU密集型任务,它们不太关心响应时间,较长的时间片长可以减小上下文切换带来的消耗。

CFS:Linux的彻底公平调度

本节咱们将介绍一个平时打交道最多的调度算法,Linux系统下的CFS(Completely Fair Scheduler,彻底公平调度)。与上一节介绍的MLFQ不一样,CFS并不是以优化周转时间和响应时间为目标,而是但愿将CPU公平地均分给每一个任务

固然,CFS也提供了给进程设置优先级的功能,让用户/管理员决定哪些进程须要得到更多的调度时间。

基本原理

大部分调度算法都是基于固定时间片来进行调度,而CFS另辟蹊径,采用基于计数的调度方法,该技术被称为virtual runtime

CFS给每一个任务都维护一个vruntime值,每当任务被调度以后,就累加它的vruntime。好比,当任务A运行了5ms的时间片以后,则更新为vruntime += 5ms。CFS在下次调度时,选择vruntime值最小的任务来调度,好比:

那CFS应该何时进行任务切换呢?切换得频繁些,任务的调度会更加的公平,可是上下文切换带来的消耗也越大。所以,CFS给用户提供了个可配参数sched_latency,让用户来决定切换的时机。CFS将每一个任务分到的时间片设置为 time_slice = sched_latency / n(n为当前的任务数) ,以确保在sched_latency周期内,各任务可以均分CPU,保证公平性。

好比将sched_latency设置为48ms,当前有4个任务A、B、C和D,那么每一个任务分到的时间片为12ms;后面C和D结束以后,A和B分到的时间片也更新为24ms:

从上述原理上看,在sched_latency 不变的状况下,随着系统任务数的增长,每一个任务分到的时间片也随之减小,任务切换所带来的消耗也会增大。为了不过多的任务切换消耗,CFS提供了可配参数min_granularity来设置任务的最小时间片。好比sched_latency设置为48ms,min_granularity设置为 6ms,那么即便当前任务数有12,每一个任务数分到的时间片也是6ms,而不是4ms。

给任务分配权重

有时候,咱们但愿给系统中某个重要的业务进程多分配些时间片,而其余不重要的进程则少分配些时间片。但按照上一节介绍的基本原理,使用CFS调度时,每一个任务都是均分CPU的,有没有办法能够作到这一点呢?

能够给任务分配权重,让权重高的任务更多的CPU

加上权重机制后,任务时间片的计算方式变成了这样:

好比,sched_latency仍是设置为48ms,现有A和B两个任务,A的权重设置为1024,B的权重设置为3072,按照上述的公式,A的时间片是12ms,B的时间片是36ms。

从上一节可知,CFS每次选取vruntime值最小的任务来调度,而每次调度完成后,vruntime的计算规则为vruntime += runtime,所以仅仅改变时间片的计算规则不会生效,还需将vruntime的计算规则调整为:

仍是前面的例子,假设A和B都没有I/O操做,更新vruntime计算规则后,调度状况以下,任务B比任务A可以分得更多的CPU了。

使用红黑树提高vruntime查找效率

CFS每次切换任务时,都会选取vruntime值最小的任务来调度,所以须要它有个数据结构来存储各个任务及其vruntime信息。

最直观的固然就是选取一个有序列表来存储这些信息,列表按照vruntime排序。这样在切换任务时,CFS只需获取列表头的任务便可,时间复杂度为O(1)。好比当前有10个任务,vruntime保存为有序链表[1, 5, 9, 10, 14, 18, 17, 21, 22, 24],可是每次插入或删除任务时,时间复杂度会是O(N),并且耗时随着任务数的增多而线性增加!

为了兼顾查询、插入、删除的效率,CFS使用红黑树来保存任务和vruntime信息,这样,查询、插入、删除操做的复杂度变成了log(N),并不会随着任务数的增多而线性增加,极大提高了效率。

另外,为了提高存储效率,CFS在红黑树中只保存了处于Running状态的任务的信息。

应对I/O与休眠

每次都选取vruntime值最小的任务来调度这种策略,也会存在任务饿死的问题。考虑有A和B两个任务,时间片为1s,起初A和B均分CPU轮流运行,在某次调度后,B进入了休眠,假设休眠了10s。等B醒来后,vruntime_{B}vruntimeB​就会比vruntime_{A}vruntimeA​小10s,在接下来的10s中,B将会一直被调度,从而任务A出现了饿死现象。

为了解决该问题,CFS规定当任务从休眠或I/O中返回时,该任务的vruntime会被设置为当前红黑树中的最小vruntime值。上述例子,B从休眠中醒来后,vruntime_{B}vruntimeB​会被设置为11,所以也就不会饿死任务A了。

这种作法其实也存在瑕疵,若是任务的休眠时间很短,那么它醒来后依旧是优先调度,这对于其余任务来讲是不公平的。

写在最后

本文花了很长的篇幅讲解了几种常见CPU调度算法的原理,每种算法都有各自的优缺点,并不存在一种完美的调度策略。在应用中,咱们须要根据实际的工做负载,选取合适的调度算法,配置合理的调度参数,权衡周转时间和响应时间、任务公平和切换消耗。这些都应验了《Fundamentals of Software Architecture》中的那句名言:Everything in software architecture is a trade-off.

本文中描述的调度算法都是基于单核处理器进行分析的,而多核处理器上的调度算法要比这复杂不少,好比须要考虑处理器之间共享数据同步缓存亲和性等,但本质原理依然离不开本文所描述的几种基础调度算法。

参考

  1. Operating Systems: Three Easy Pieces, Remzi H Arpaci-Dusseau / Andrea C Arpaci-Dusseau
  2. 计算机系统基础(三):异常、中断和输入/输出, 袁春风 南京大学

 

点击关注,第一时间了解华为云新鲜技术~

相关文章
相关标签/搜索