任务调度
在讲完多线程并发之后,我们终于可以进入进程管理的最后一部分内容,任务调度。一些参考书上把这一部分内容叫做进程调度,我们之所以叫它任务调度,是因为在很多系统中、被调度的单位并不一定是进程。上一章开头我们已经提到,Linux 系统中所有线程都是内核级线程,因此一个线程可以作为一个相对独立的单位被调度器调度。我们将一个被调度的单位称为任务(task),这一章中我们就来认识一下任务调度的常用算法。
上下文切换
任务调度发生的情境——上下文切换
上下文切换是从一个任务向下一个任务切换的过程,它有可能由两种情形触发。
- 一种情形是任务没有运行完,但主动让出了处理器使用权;依赖这种形式触发上下文切换的系统是 合作式多任务系统(cooperativemultitasking)。
- 另一种情形是操作系统在任务运行一段时间后向进程发出中断,然后在中断处理器中选择下一个运行的任务、切换至该进程。这种系统叫做 抢占式多任务系统(preemptive multitasking)。
我们所谈到的任务调度是基于后一种多任务系统,即操作系统必须拥有抢占处理器的能力。
在抢占式多任务系统中,每个任务会被给予一段时间,我们将这段时间称为 时间片(time slice)。
时间片耗尽后,系统会引发计时器中断(timer interrupt),使得现在正在运行的任务被切换至内核态,在系统空间中还行计时器中断的处理函数,我们所关心的任务调度算法就发生在计时器中断的处理函数中。
在这个处理函数中,系统会根据任务调度算法、从就绪队列里选择下一个运行的任务(有可能仍然是现在的这个任务),然后在处理上载入这个任务的处理器状态、在存储管理部件里载入这个任务对应的页表的起始地址、开始运行。
衡量任务调度算法表现的量
为了使用户获得流畅的体验,我们希望一个好的任务调度算法能够让所有任务在一段时间内都产生一定的进度、不让任何任务等待太久、又不浪费太多时间在算法本身的计算中。为了能有效衡量一个算法是否符合上面的标准,我们接下来要介绍几个用来衡量任务调度算法表现的量。
学完了一个理想调度算法应符合的标准后,我们就可以开始学习各种调度算法了。我们将在学习具体的算法的过程中,在不同的情境下用这些标准来衡量调度算法。你很快就会意识到,所有算法都有其最优情境和最差情境。在选择算法时,我们也要根据不同的情境来选择合适的算法。
练习
SJF与SRTF
还记得在讲页面替换算法时,我们讲的第一个算法就是最佳页面替换算法;虽然最佳页面替换算法要求我们能够预知未来,但它可以被证明是在同一页面使用序列下缺页中断率最低的算法。这一节中我们要讲的两个算法也是这样——它们实际上很难被实现,但是它们能够保证最短的平均周转时间,因此我们在学习其它可以实现的算法时就能以这两个算法为基准衡量其它算法的表现。
-
最短作业优先算法 SJF
-
最短时间优先算法SRTF
从上面的例子我们可以看出,最短剩余时间优先算法是优于非抢占式的最短作业优先算法的。因此,我们在谈到最短作业优先算法时指的一般都是抢占式最短作业优先算法(最短剩余时间优先算法)。接下来我们就来证明下面这个结论:对于同一个任务流,最短剩余时间优先算法可以使所有任务的平均周转时间达到最短。
我们可以将 SRTF 看成是一个动态的 SJF,它在每次有新任务进入系统时都重新运行 SJF。因此我们只要证明,对于一个固定的任务流(没有新任务加入的任务流)SJF 的平均周转时间是最短的,我们就可以证明 SRTF 对于一个有新任务加入的任务流的平均周转时间是最短的。(因为我们在每一个时间点上都做了最佳的选择,那么我们整体的选择也是最佳的)
下面我们就来证明 SJF 能够针对一个固定的任务流给出最低的平均周转时间。为了证明这个结论,我们先来证明一个辅助定理:穿插运行几个任务比连续运行几个任务所需时间更长。这个证明是很简单的,因为穿插运行的几个任务中后结束的任务的周转时间一定大于等于它之间结束的几个任务的运行时间与它的运行时间之和。因此为了减小周转时间,我们希望上面的值取等,这就相当于要求所有的任务都连续运行至结束再切换至下一个任务运行。
从上面的证明中我们已经看出,SRTF 和 SJF 的优点就是它们能够缩短任务平均周转时间。但 SRTF 和 SJF 也有明显的缺点——在短任务不断进入系统的情况下,它们会导致运行时间长的任务不断等待,产生饥饿。下一节中我们可以看到,为了减少任务的等待时间、提高公平性,我们可以采用一些别的算法。
HRRF,FIFO 与 Round-Robin 调度算法
-
最高响应比优先算法(Highest Response Ratio First,HRRF)
先进先出算法(First in First Out,FIFO),它又被称为 先来先服务算法(First Come First Served,FCFS)
它跟页面替换算法里学习的先进先出算法类似,使先进入系统的任务先运行至结束,然后再使下一个进入系统的任务运行。这种算法不会使后进入系统的短任务耽误先进入系统的长任务,因此解决了 SJF 和 SRTF 的问题,但 FIFO 也有自己的问题。
如果一个长任务先进入系统,后面有很多短任务进入系统,那么这些短任务都需要等待长任务运行完之后才能运行,平均周转时间会变得很长;另一方面,如果一个任务的大部分时间都花在 I/O 操作上,那么 FIFO 这种非抢占式的算法会导致 CPU 长时间空闲,降低了资源利用率。
- 轮转调度算法(Round-Robin,RR)
我们可以看到 FIFO 和 SJF、SRTF 一样都有公平性较差的问题,只不过 SJF 和 SRTF 偏向短任务,而 FIFO 偏向长任务。我们想要一个更公平的算法,使得不同的任务在一段时间内都能产生进度,这就是 轮转调度算法(Round-Robin,RR)
在 RR 中,我们会规定一个固定长度的时间片。处于就绪队列中的进程将按照他们进入就绪队列的顺序轮流获得一个时间片运行,当时间片耗尽时任务就会被中断、加入就绪队列的结尾,下一个任务开始运行。如果一个任务因为 I/O 操作或无法获取锁等原因被阻塞,那么即使它无法用完自己的时间片也会放弃处理器的使用权。
RR 与 SRTF 一样,都是抢占式算法,它的优点是对于计算密集型的任务分配资源较为公平,且不会产生饥饿。然而,I/O 密集型的任务在这种算法下会不断因 I/O 放弃处理器使用权,因此获取的资源较少。不仅如此,RR 算法的平均周转时间较长,这主要有两个原因——第一,原本只需要几个时间片就可以运行完的任务在 RR 中需要等待整个就绪队列的任务都运行完一遍以后才能获得一次时间片,因此等待时间很长;第二,每次时间片消耗完或任务主动让出处理器时都会出现上下文切换,而上下文切换的代价是很大的,因此如果时间片太小、浪费在上下文切换上的时间比例就会很大。由于上述这种原因,在某些情况下 RR 的平均周转时间可能比 FIFO 的平均周转时间更长。
练习
多级反馈队列调度算法(Multi-Level Feedback Queue,MLFQ)
前几节中我们讲了 FIFO 和 RR 这两种比较基本的算法,它们各自都有最适宜(optimal)和最不适宜(pessimal)的任务流种类。为了避免某一种基本算法在一些不适宜使用该算法的任务流下表现很差,我们需要以这些基本算法为基石、设计一些更综合的算法。这一节中我们就来讲一个在很多系统中都得到应用的、比较综合的算法:多级反馈队列调度算法(Multi-Level Feedback Queue,MLFQ)
优先级捐赠的过程看起来简单,其实是很复杂的。比如,如果低优先级线程 1 持有锁 B,中优先级线程 2 持有锁 A,高优先级线程 3 试图获得 A 失败后将被阻塞,将自己的优先级捐赠给线程 2;线程 2 运行后试图获得 B 失败后也被阻塞,这时它就必须把线程 3 捐赠给它的优先级和它自己的优先级同时捐赠给线程 1,因为如果它只捐赠自己的优先级,那么线程 1 的优先级仍然不一定是最高的。 我们把这种循环式的捐赠叫做 recursive donation。
还有一种情况我们必须考虑,那就是在一个线程持有多个锁并释放其中一个的时候,我们应该把它的优先级降到什么程度呢?我们要避免剩下几个锁产生优先级倒置,就需要这个线程的优先级不低于任何一个等待它的线程的优先级,否则两个优先级之间就会出现能够抢先运行的线程。因此我们应该把它的优先级降到所有它持有的锁的等待者中的最高优先等级。我们甚至可能面临这样一种情况:线程释放的锁不是把这个线程的优先级提高到目前等级的线程所等待的锁,在这种情况下,线程的优先级就不会变化了。
虽然我们管这种提升优先级的方式叫捐赠,但更好的考虑方法应该是一种“暂时提拔”。捐赠会给人一种捐多少就要还多少的感觉,但实际上捐赠时线程优先级增加与它释放锁时优先级减少的值并不一定相等(在线程持有多个锁时很可能不相等)。对于这一部分内容的理解至关重要,在下一节中我们就来测试一下你对于这一部分内容的掌握程度,帮助你巩固你的理解。
上一节中我们探讨了优先级倒置的情况和它的解决方法;在探讨这个问题时我们故意避开了几个知识点没有讨论,这一节里就请你自己来思考一下下面几个有关优先级捐赠的说法哪两个是正确的。
练习
Linux调度算法
这种看似相同的运行时间片长度从比例上来讲相差是很多的。我们想要一个更加公平、且普遍适用于各种类型的任务的算法。
练习
多处理器调度
如你所知,现代的很多大型计算机都是多处理器的;在大的数据中心中(如谷歌的数据中心),一整个装满处理器和磁盘的仓库可能是同一台计算机(这种计算机被称为 Warehouse-Scale Computer,WSC)。在这种情况下,一个操作系统的调度器就需要处理将工作分摊到多个处理器上的功能。你可能会问,为什么不直接按照单处理器的调度算法给空闲的处理器分配任务呢?接下来我们就来看看,按照这种思路调度任务会有什么问题。
我们知道,一个任务不一定等于一个进程;一个线程也可以是一个任务。那么,如果一个进程希望充分利用多处理器的资源,它就可以分出多个线程,并行地完成一段计算,完成这段计算后再合并线程或让所有线程进入下一阶段的计算。然而,在计算机中,由于系统把这几个线程当成独立的任务,系统可能把它们分开运行,这样就会出现所有线程都需要等待最慢的那个线程完成才能进入下一阶段的问题,并行对于运算速度的提升就大打折扣了。如果几个线程中有一个获得了一个锁,然后它的处理器被抢占了,那么剩余的线程即使获得了处理器也只能等待锁。如果这个锁是自旋锁的话,那么所有这个进程下的线程都会在自旋中浪费处理器时间,导致其它进程也不能产生进度。
可见,这种盲目(oblivious)地利用单处理器的方法在多处理器计算机上调度任务的方法是不可行的。
排队论
我们要区分等待和运行这两部分,因为我们使用排队论的目的就是分析一个任务在等待和运行这两个部分分别花费的时间长度。前面几节中我们已经说过,一个任务的周转时间等于它的等待时间与运行时间之和,假设其运行时间是不变的,那么它的等待时间就成为了限制系统表现的主要问题。如果一个系统中运行的部分时间较短、而等待时间很长,那么我们就应该考虑换一种调度算法或者多买几个处理器;如果一个系统中运行部分时间和等待时间都很长,那我们就应该考虑,等待时间过长可能是由系统处理速度慢于任务进入系统的速度导致的,那我们就有必要改一改现在运行所使用的算法或买一个更快的处理器了。
练习
我们现在有一个系统,每秒钟可以处理 100 个请求,每个请求的处理时间(不包括排队时间)是 100ms,,下面有关这个系统的说法正确的两个是?