OS是一个非常庞大的软件系统,本文主要探索其中的冰山一角:CPU的调度原理。
在探索CPU调度原理之前,我们先了解一下CPU的上下文切换,它是CPU调度的基础。
OS在切换运行任务时,将上一任务的上下文保存下来,并将即将运行的任务的上下文加载到CPU寄存器上的这一动作,被称为CPU上下文切换。
CPU上下文属于进程上下文的一部分,我们常说的进程上下文由如下两部分组成:
这涉及到两个问题:(1)上一任务的CPU上下文如何保存下来?(2)什么时候执行上下文切换?
问题1:上一任务的CPU上下文如何保存下来?
CPU上下文会被保存在进程的内核空间(kernelspace)上。OS在给每个进程分配虚拟内存空间时,会分配一个内核空间,这部分内存只能由内核代码访问。OS在切换CPU上下文前,会先将当前CPU的通用寄存器、PC等进程现场信息保存在进程的内核空间上,待下次切换时,再取出重新装载到CPU上,以恢复任务的运行。
问题2:什么时候执行上下文切换?
协作式策略依赖用户程序主动让出CPU,比如执行系统调用(SystemCall)或者出现除零等异常。但该策略并不靠谱,如果用户程序没有主动让出CPU,甚至是恶意死循环,那么该程序将会一直占用CPU,唯一的恢复手段就是重启系统了。
抢占式策略则依赖硬件的定时中断机制(TimerInterrupt),OS会在初始化时向硬件注册中断处理回调(InterruptHandler)。当硬件产生中断时,硬件会将CPU的处理权交给来OS,OS就可以在中断回调上实现CPU上下文的切换。
对于一种CPU调度算法的好坏,一般都通过如下两个指标来进行衡量:
OS上的工作负载(也即各类任务运行的状况)总是千变万化的,为了更好的理解各类CPU调度算法原理,我们先对工作负载进行来如下几种假设:
准备工作已经做好,下面我们开始进入CPU调度算法的奇妙世界。
FIFO(FirstInFirstOut,先进先出)调度算法以原理简单,容易实现著称,它先调度首先到达的任务直至结束,然后再调度下一个任务,以此类推。如果有多个任务同时到达,则随机选一个。
在我们假设的工作负载状况下,FIFO效率良好。比如有A、B、C三个任务满足上述所有负载假设,每个任务运行时长为10s,在t=0时刻到达,那么任务调度情况是这样的:
让我们继续打破假设2,A在t=0时刻,B和C则在t=10时刻到达,那么调度情况会变成这样:
为了解决SJF的任务饿死问题,我们需要打破假设3,也即任务在运行过程中是允许被打断的。如果B和C在到达时就立即被调度,问题就解决了。这属于抢占式调度,原理就是CPU上下文切换一节提到的,在中断定时器到达之后,OS完成任务A和B的上下文切换。
更糟糕的是,随着任务运行时长的增长,平均响应时长也随之增长,这对于交互类任务来说将会是灾难性的,严重影响用户体验。该问题的根源在于,当任务都同时到达且运行时长相同时,最后一个任务必须等待其他任务全部完成之后才开始调度。
CPU上下文切换的消耗,不只是保存和恢复寄存器所带来的消耗。程序在运行过程中,会逐渐在CPU各级缓存、TLB、分支预测器等硬件上建立属于自己的缓存数据。当任务被切换后,就意味着又得重来一遍缓存预热,这会带来巨大的消耗。
到目前为止,我们并未考虑任何的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的空转!
接下来,我们将介绍一个能够在所有工作负载假设被打破的情况下依然表现良好,被许多现代操作系统采用的CPU调度算法,MLFQ。
MLFQ(Multi-LevelFeedbackQueue,多级反馈队列)调度算法的目标如下:
那么对MLFQ来说,就需要解决如下两个问题:
MLFQ与前文介绍的几种调度算法最显著的特点就是新增了优先级队列存放不同优先级的任务,并定下了如下两个规则:
规则3主要考虑到让新加入的任务都能得到调度机会,避免出现任务饿死的问题
按照上述规则,当一个long-running任务A到达时,调度情况是这样的:
如果在任务A运行到t=100时,short-time任务B到达,调度情况是这样的:
如果任务A运行到t=100时,交互类任务C到达,那么调度情况是这样的:
考虑如下场景,任务A运行到t=100时,交互类任务C和D同时到达,那么调度情况会是这样的:
由此可见,如果当前系统上存在很多交互类任务时,CPU密集型任务将会存在饿死的可能!
为了解决该问题,可以设立了如下规则:
加上该规则之后,假设设置S为50ms,那么调度情况是这样的,饿死问题得到解决!
为了解决该问题,我们需要将规则4调整为如下规则:
应用新的规则4后,相同的工作负载,调度情况变成了如下所述,不再出现恶意任务E占用大量CPU的问题。
到目前为止,MLFQ的基本原理已经介绍完,最后,我们总结下MLFQ最关键的5项规则:
现在,再回到本节开始时提出的两个问题:
2、MLFQ如何从历史调度中学习,以便未来做出更好的决策?
MLFQ主要根据任务是否有主动让出CPU的行为来判断其是否是交互类任务,如果是,则维持在当前的优先级,保证该任务的调度优先权,提升交互类任务的响应性。
可以给任务分配权重,让权重高的任务更多的CPU!
还是前面的例子,假设A和B都没有I/O操作,更新vruntime计算规则后,调度情况如下,任务B比任务A能够分得更多的CPU了。
为了兼顾查询、插入、删除的效率,CFS使用红黑树来保存任务和vruntime信息,这样,查询、插入、删除操作的复杂度变成了log(N),并不会随着任务数的增多而线性增长,极大提升了效率。
另外,为了提升存储效率,CFS在红黑树中只保存了处于Running状态的任务的信息。
为了解决该问题,CFS规定当任务从休眠或I/O中返回时,该任务的vruntime会被设置为当前红黑树中的最小vruntime值。上述例子,B从休眠中醒来后,vruntime_{B}vruntimeB会被设置为11,因此也就不会饿死任务A了。
本文中描述的调度算法都是基于单核处理器进行分析的,而多核处理器上的调度算法要比这复杂很多,比如需要考虑处理器之间共享数据同步、缓存亲和性等,但本质原理依然离不开本文所描述的几种基础调度算法。