YVR18资料关注点5:当前的Linux调度器设计

演讲220对Linux当前的调度器做了一个科普,感觉不深不浅的,不知道对大部分读者是否具有参考价值。我对来说,已经很久没有看Linux的调度器了,很多原来没有很明确的概念,经过这些年的发展,现在变得非常清晰,所以参考价值还是挺大的。我就着这个演讲描述的概念,以及我自己掌握的一些东西,为这里的读者普及一些Linux调度器的初步知识,也算是我自己对这部分信息的一个总结吧。


我们先来理解一下调度器面对的问题。我不知道没有写过调度器的读者是否会和我一样,在我自己做操作系统设计之前,比如在学校学习操作系统原理的时候,我对调度器的认识,有一个很大的误区,似乎调度器是“决定把哪个进程投入运行”的一个算法,但实际上,它是“决定把哪个要运行的进程投入运行”的一个算法。这句话听起来一样,其实是不一样的,后者意味着,在每个调度“时刻”,你只需要管要运行的进程,不用管其他进程。我们很容易从一个时间广度上考虑这个问题,觉得调度器需要考虑所有的进程的状态,实际上调度器只考虑现在就可以运行的进程的状态,算法只需要考虑在调度序列中的进程,其他进程,都是不管的。这个现在单独跟你说,你会觉得“这谁不知道啊”,但等你看算法的时候,你可能就晕菜了。我们先把这个前提放在这里,以便读者后面更容易理解概念。

其实也正因为这个理解不同,我们更多人能接受“CPU占用率”这个概念,而不是Load这个概念,CPU占用率是时间广度的,是人的概念,而Load是一个时刻深度的,是调度器的概念。人关心的是某段时间内,CPU的利用率有多高,一个时刻是没有CPU占用率这个概念的。而调度器关心的是现在还有多少了进程等着被我调度,我让谁先上来,所以,这些被等着调度的进程,就是我的Load。


理解CPU占用率和Load的分别,我们就会发现,调度器其实比我们想象中简单,因为调度器是不考虑你的历史的,调度器考虑的是你这个进程加入到我的调度中后,我把你排在第几位执行,如果你休眠了,你的历史就被清除了,我才不在乎你过去用了多少CPU呢(其实不完全是这样,但我们先这样理解)。


有了这些基础,我们现在来理解一下调度器面对的问题。首先,我们有一些任务是很重要的,如果它要运行,就必须让它先运行。这我们称为实时任务。实时任务是最容易处理的。我刚入行的时候,一位做Unix OS的前辈就跟我说,RT调度器那就是玩具,基本上就让它先执行就好了。同是RT进程的话,也只有Round Robin和FIFO两种算法,如何工作你猜都能猜到,最多就是补充一些优先级反转之类的保护,基本上没有什么值得发展的。这部分的算法,本文也会忽略。


难的是普通的任务怎么调度。一个简单的思路,根据任务的优先级(nice),每个任务给定一个调度时间片,然后每个任务用完自己的时间片,就等着,等到所有的任务都用完自己的时间片了,就重新开始。


但你真的按这样的方法来试试,你就会发现,你这个系统基本上不可用。为什么呢?因为任务有两种,一种是io bound,一种是cpu bound的。io bound的任务处理io,cpu bound的是长时间执行,只是在消耗CPU。如果你平等地对待他们,每个任务执行50ms,10个cpu bound的任务,1个shell,然后你在shell上按下一个a,这个a要等500ms才能回显出来,这玩意儿没法用。要保证io bound的进程在前面,否则这东西没法用。这是大部分普通调度器要解决的问题。


Linux在O(1)之前的调度器基本上是个玩具,那个东西我们就忽略了。我们先看O(1)调度器的原理。从名字就能看出来,O(1)算法是要保证取下一个运行任务的时候,算法复杂度是O(1),它用这样的数据结构:

待运行的任务都挂在Active队列下面,每个Active分优先级Hash开,在用一个bitmap标记哪个队列中有任务,这样,要投入运行,只要检查一下bitmap,然后拿那个队列的第一个任务运行就可以了(这就是这个算法称为O(1)的原因)。当一个任务的时间片用完了,就改挂到Expired队列。等Active队列空了,就把两者换过来,问题就递归了。


这个算法最大的破绽你也看到了,它区分不了谁是io bound进程。所以O(1)算法有一个非常不好看的补充算法,主要是根据每个任务是否能用完自己的时间片就离开调度队列,如果是这样,调度器就“补偿”它,提高它的Effective优先级,这样,它回来的时候,就可以比较早得到调度了。我以前玩得比较多的就是这个算法,这个东西经常错判,而且很难调试。后来,它就逐步被CFS取代了。


CFS在2.6.23开始引入内核,在2.6.30彻底取代了O(1)算法。它引入的变化首先是用sched_class把不同的调度算法彻底分开了。正如演讲220中提到的,现在调度分了两层,先按调度类别分类,优先调度高优先级类别的任务。这样,我们做普通调度的时候,就不再需要考虑比如实时任务这样的任务了。

比如现在的内核中就包含了这些类别:

STOP:系统任务,比如RCU,ftrace,核间迁移。这些任务凌驾于所有其他任务有限调度

DL:Dead Line任务,这些任务有“必须什么时候完成”这样的诉求,所以在所有客户任务中优先调度

RT:就是过去的实时任务了

CFS:这才是普通的任务调度

IDLE:这是IDLE任务swapper/N

这一层的原理非常直白了。


然后,我们仍单独理解CFS。完全公平调度。首先我们理解一下什么是“完美的公平调度”,比如说,你有4个任务a, b, c, d,分别要运行4,4,8,12毫秒,CPU的时间片单位是4ms。

那么前四个4ms,应该是a, b, c, d每个周期各运行1ms,第五、六个4ms,a,b不在了,c,d应该每个周期各运行2ms,这样,c也运行完了,剩下的d,再运行第七个4ms,把4ms全部用完。这样就是完美的完全公平。

但我们做不到,因为我们不能无时无刻去比这些时间。所以,CFS就是一种“尽量公平调度的方法”,每次到了一个调度点(比如时钟中断),它马上算一下现在的任务花了多少时间,把这个时间加到它的vruntime中,之后调度的时候,总是取一个vruntime最短的任务来执行。

这样,天然地,运行得最少,经常休眠的任务的优先级就会变高,总是优先得到调度了。

这个算法纯从计算上逼近iobound进程优先执行。比O(1)算法可控多了。

但它的破绽也是很明显的,如果你要装你是个iobound进程,你只要避开vruntime的计算点,每次休眠一点点时间,就能保持你的优先级。

所以,实际上CFS还有很多补充算法来解决很多具体的问题,但无论如何,这个模型还是比O(1)可控。

其实吧,也没有保证能公平的调度算法,这最后基本上就是调整出来的。也许等待AI的影响力足够强,这东西应该是通过神经网络自动训练出来的?

文章被以下专栏收录