0.多进程调度的本质

我们都知道UNIX上有一个著名的nice调用。何谓nice,当然是“好”了。常规的想法是nice值越大越好,实际上,nice值越好,自己的优先级越低。那么为何不用badness呢?
       其实。假设我们理解了操作系统多进程调度系统是一个“利他”系统,这个问题就不是个问题了。nice当然还是好。不是对自己好。而是对别人好。利他系统是一个人人为我我为人人的系统,相似还有TCP流量控制和拥塞控制。人类的宗教社会组织等等,利他系统都有一个负反馈机制。让波动保持在一个同样的范围内,你少了,大家每人给你捐一点点,积少成多。没人能够一次性帮到你,由于没人能够大量结余。反之,“由于凡是有的。还要给他,他就充足有余。凡是没有的,就连他有什么也要拿去。”则是一个自私的系统了,比方UDP或者实时进程。或者没有信仰的某国人...

1.UNIXv6的利他性体现

在UNIX中,进程的用户态优先级由一个公式表述(数值越小,优先级越高):
prio = USER + p_cpu/4 + 2*nice
解释一下当中的字段:
USER:是用户态的基准优先级。这是为了保证但凡生活在用户态的进程的优先级都高于USER。由于处在内核态的优先级都要小于USER。保证机要机构高速出入。
p_cpu:进程的CPU占用时间。

值得注意的是。在4.3BSD以后的BSD中,这个字段并非线性添加的,即不是每来一个时钟中断就会递增1,而是依照“越接近如今越重要,历史越久远越不重要”的原则衰减的。衰减因子为:(2*load)/(2*load+1).可见,越是离如今久远的CPU占用。越对总的CPU占用时间具有小的影响。为什么要强调p_cpu呢?由于在利他系统中。你占用CPU的时间越久,就越不符合利他行为的特征。随着p_cpu的积累,prio会逐渐添加。造成优先级降低,进而让出CPU。终于的行为还是利他的。

那么为何要有一个衰减指数呢?在利他系统中,长期占用CPU是一个“错误”的行为,可是假设知错能改,也能够既往不咎。非常久曾经的CPU占用仅仅起到參考作用就可以,没有决定性作用。

利他系统中,目的不在惩处。
       在p_cpu的衰减因子中。有一个load。它指的是系统的总负载,也就是总进程数,你会发现,负载越大,因子越小,由于因子小于1,所以因子越小,p_cpu添加的速度越快。进程优先级的降低越快,每个进程的调度周期越短,保证全部进程的调度周期保持在一个大致同样的范围内,每个进程都不会过度饥饿。
nice:利他行为的直接体现,nice越大优先级越低。
我们总体看一下这个prio的计算公式。

假设nice是负值,说明这个进程不是那么“利他”,由于它的nice值并非那么的NICE!那么此时p_cpu就起到牵制它的作用了,假设nice的值是一个正值,而且非常大,则说明它在利他系统中非常NICE!此时p_cpu的意义就不大了。因此p_cpu的作用要除以4来造成“忽略最小项”的效果,我们再看nice是负值的情况p_cpu除以4的意义。在那种情况下,nice的值造成进程持续占领CPU,即便p_cpu的值除以4,它也是一个不小的值。

最后。注意。即便在利他系统中,也不是人人平等的,这就是nice值限制在一个范围内而不是一个固定值的原因,有些进程比較重要,它的nice值理所当然的低,没说的。

举个样例,即便在宗教系统,也分教皇。主教...
       理解了朴素的UNIX调度机制后。我们会发现它是怎样得朴素和优美,每个进程的优先级上下波动,随时抢占(包含时钟中断发现了更高优先级的进程以及唤醒后的低内核优先级抢占。注意后者本文没有介绍,请參考UNIXv6或者《Windows Internals》),p_cpu衰减因子的计算保证即便系统负载非常大,进程也不会过度饥饿,负载的添加对进程造成的影响仅仅是每个进程每次执行的时间降低了,由于切换密集了,这个策略十分适用于桌面交互式应用。
       我给出一幅图,总体描写叙述一下UNIXv6的调度:

2.调度算法问题

注意,在以上的描写叙述中,我并没有描写叙述系统怎么检索出优先级最高的进程,以及怎样来维护全部的进程,是保存在数组,还是链表。或者其他数据结构,比方AVL树。红黑树...这是实现问题,并非本质问题,第一个版本号的实现一般都是数组或者链表实现的。为的是简单,尽快出一个能够执行的版本号,再往后会迁移到更加高效的数据结构上。比方红黑树,这是优化问题。
       假设你读过Linux的0.11版本号内核代码一直到2.4内核的代码,你会发现系统在检索优先级最高的进程时使用的都是链表遍历。这被称为O(n)算法,旨在表达这样的算法的时间复杂度和系统负载成正比,人们一般仅仅关注算法实现问题,网上遍布着大量的O(n),O(1)算法的文章,可是差点儿没有描写叙述优先级计算的文章,而后者要比前者更重要。我们知道。Linux 2.6.0到2.6.23以及Windows NT的调度器实现都是O(1)的,能够说是差点儿一样。它们都为每个优先级维护了一个链表。每个CPU有一个位图,指示哪个优先级有进程/线程就绪,所不同的是Windows NT依赖动态优先级提升以及基本优先级回落而仅仅维护一个链表组,而Linux的O(1)则是维护了两个链表组,由于在Linux中优先级提升仅仅是一个辅助的优化。

这又有什么意义呢?
       我想说一句。在2008年。我实现了一个调度器。当时是在2.6.18内核上,当时还没有CFS(或许有了。可是我没有查到什么资料)。我们当时的需求是实时音视频传输,但同意丢帧。而且系统行为不随着系统负载的变化而变化。我当时的设计是将进程执行的平均时间片设置成一个常量T,然后计算:
Tt = nr_task * T
将Tt划分为nf_task个片段,每个片段长度为:
Ttn = Tt*(Wn/Wt)
当中,Wn为进程n的权重,而Wt为总权重。关于权重,我仅仅是简单依照nice进行归一化,保证总的全部的nice'即weight值的和为1。以上就是一个简单的设计,你是不是认为和CFS狠像呢?我也认为!

可是在实现上,我却使用了链表而不是红黑树,由于链表简单。

后来想用堆实现,也作罢了。链表遍历不是啥大不了的事儿!

后来我看CFS,发现当中的下面代码来表示全部进程调度一遍的周期:

if (unlikely(nr_running > 5)) {
        period = sysctl_sched_min_granularity;
        period *= nr_running;
}
认为CFS和我2008年的那个调度器实现犯了同一个错误。那就是假设nr_running非常之大,就会导致调度一遍的时间非常长久,依照BSD4.3 UNIX的思想,应该阻止添加全部进程调度一遍的时间才对。好吧。那我能限制period的最大值吗?能够!可是假设优先级公式本身保证了这一点。岂不是更加完美吗?

3.时间片调度

多数现代操作系统的调度都是基于时间片的。所不同的是时间片和进程优先级的关系。

对于Linux的O(n)以及O(1)调度器来讲。时间片都是优先级的函数。时间片的还有一个计算因子就是进程执行的特性,总体加起来计算一个动态优先级。对于Windows NT而言,全部线程的时间片是固定的,优先级包含线程的基本优先级数值以及动态调整的数值,不同优先级线程的每次执行时间都是一致的,所不同的是高优先级线程总是能够抢占低优先级线程从而先执行。这是和UNIXv6进程调度的本质差别,再次重申,UNIXv6的调度不是基于时间片的,进程根本就没有时间片。
       时间片调度的优点在于时间片是预先分配的。降低了每次计算全部进程的动态优先级的开销,添加了系统吞吐能力,可是传统的时间片计算方式有一个缺点。那就是easy造成进程/线程饥饿。尽管WIndows NT採用了固定时间片来避免某优先级线程时间片过长。可是它不得不靠外部的平衡器来适时地发现饥饿线程并动态调整其到高优先级队列。UNIXv6的进程调度(其实说的是后来的BSD4.3+)全然没有这个问题:
a.依靠一个单一的公式来适应系统的负载,动态平滑调整优先级;
b.分离内核态执行优先级,提高I/O完毕后的响应速度。

3.1.可变时间片调度

Linux 2.6.23之前採用的是可变时间片调度。

它将时间片描写叙述为一个静态优先级的函数,对于固定的nice值,其时间片是固定的,即每个nice值相应一个时间片。然后依照先执行高优先级进程的策略来调度执行队列的进程,这会带来两个问题:
1.假设大量进程处在同样的高优先级,将会造成它们之间轮转执行。造成低于它们的全部优先级的进程持续饥饿;
2.I/O后的高速响应问题。
我们看一下Linux 2.6.23之前是怎么解决这些问题的。

这能够分类两种方案,对于O(n)算法而言。系统严格执行完预分配给进程的时间片而且仅仅执行1次,然后将其时间片设置为0,接下来当全部的进程时间片均为0的时候,一次性又一次设置全部进程的时间片,由于每个进程仅仅执行1次自己的时间片,那么即便它拥有再高的优先级。也总实用完的那一刻,这就避免了进程永远饥饿,对于I/O完毕的高速响应问题,O(n)并没有非常好的解决!注意,O(n)调度器是不可抢占的。即进程在时间片用完之前。不可抢占。
       对于O(1)算法,相似的也是进程执行完分配给自己的时间片而且仅仅执行1次。然后重置其时间片并将其放在一个过期队列中,等全部的进程都在过期数组中了。切换过期数组而活动数组。我们能够看见,实际上O(1)调度器和O(n)调度器在本质上都是一致的,没有什么除了实现方式之外的其他差别。唯一的差别在于O(1)实现了抢占,复杂到死人的动态优先级的调整。“时间片再分片”以及饥饿避免机制:
内核抢占:刑可上大夫。仅仅要没有占用机要机构,无论是内核态还是用户态的进程,也无论时间片有没实用尽,均能够被抢占。这实际上是减小了内核保护的粒度。
动态优先级调整:依据进程的睡眠时间和实际占用CPU时间的比率来调整优先级,原则在于。睡眠越久的进程给与的补偿越高。这也是一个利他性补偿。
时间片再分片:在特殊的交互环境下,将一个超级长的计算型进程的时间片再次分片。分成非常小的slice。为其他进程提供执行机会;
饥饿避免:由于引入了上述的机制。难免由于优先级提升导致的高优先级进程们持续占有CPU,因此引入了饥饿避免机制。这实际上相似Windows NT的平衡器。可是不同的是。WIndows NT中。优先级调整和抢占是主线。而在Linux中,它们则是辅助功能。

附:比較Linux的O(n)调度器和O(1)调度器

无论是哪一个。都有2个效果,那就是,其一,高优先级的进程首先执行,其二。高优先级进程每次执行的时间片长。这就造成了高优先级进程在系统中占有绝对的优势,调度行为严格依照调度器而不是别的额外机制。


       有没有想过。O(1)的效果并没有想象的那么比O(n)好。O(1)仅仅对于交互式应用更加合理。对于吞吐优先的server而言,甚至不如O(n),特别是内核抢占功能,在编译内核的时候,你会看到,假设你编译的是server而不是桌面版本号。建议是关闭内核抢占。由于总时间是一定的。切换时间占用多了。执行任务时间就少了。另外,对于server而言,一般都倾向于一次性完毕任务,最大化缓存的利用率,切换会导致缓存刷新。TLB刷新。


       个人感觉,O(1)并没有较O(n)提高多少性能,可能沾光的仅仅是Ubuntu,Suse之类的桌面系统吧。


3.2.固定时间片调度

Windows NT採用的是固定时间片调度,无论是高优先级还是低优先级线程,每次均执行一个固定的时间片,每次调度器选择最高优先级队列的第一个线程来执行。其思想非常easy。可是假设仅仅如此,饥饿是不可避免的,由于它不像Linux的O(n)那样限制每一轮每个进程仅仅执行1次,也不像Linux的O(1)那样维护一个过期队列。那么Windows NT是怎么解决这类问题的呢?首先看一个图吧:

Windows NT调度器的关键在于动态优先级的调整。因此总体上看。你会看到不论什么线程都是在不同一时候刻在不同的优先级队列里面蹦来跳去的,对于睡眠的线程。Windows NT差点儿全然继承了UNIXv6的睡眠唤醒优先级的思想。


       由于WinNT的调度器并没有规定“调度一轮”的概念,因此也就没有规定每个线程执行几个时间片,所以它的时间片是固定的。毕竟一个高优先级的线程可能会连续执行好几个时间片。仅仅要期间没有更高优先级的线程就绪。能够抢占高优先级线程的办法就是依赖系统将一个低优先级线程提升到一个高优先级队列,提升优先级的原因有好多:调度器事件提升,睡眠唤醒后提升;等锁控锁后提升。I/O完毕提升(延续自UNIXv6)。等待执行体资源后提升,交互式前台窗体线程提升,长期饥饿提升。

以上每一种优先级提升机制都有复杂的实现,且优先级的提升数值也不同。这说明。WinNT的调度器主线就是优先级调整,每个线程都有一个固定的基本优先级,它的作用在于线程优先级的回归。

WinNT的O(1)调度器评价

个人认为这个调度器在现代操作系统的观点看来是比較优秀的,可是还是不如Linux的CFS调度器。WInNT调度器的静止在于其依赖的外部因素特别少,非常相似UNIXv6,美中不足就是平衡器。

对于server版本号和家庭版本号,WInNT定义的时间片是不同的。server追求高吞吐。要避免频繁切换,因此时间片就会更长,而家用版本号追求高响应性,因此时间片就会更短。这也符合UNIXv6的调度器设计思想。


       WinNT调度器并没有体现利他系统应有的行为。而是全然依赖外部诸如优先级提升原因以及平衡器的操作,这就意味着它不是一个自洽的调度系统。仅仅有在WinNT这个环境下才干发挥其作用。


       我在2008年设计自己(其实是为公司)的08调度器(姑且这个命名)的初衷在于O(1)调度器无法满足实时性需求,特别是在负载非常大的时候。

时间片是固定的。进程数量越多,调度完一轮的时间就越久,这是Linux的“按轮”调度思想最大弊端。WinNT调度器就没有这样的问题。由于随便一个线程的优先级在随意时刻都能够由于随意原因而得到随意的提升。最后收摊的就是平衡器。它能保证线程不会持续4秒以上的饥饿。

可是Linux的“按轮调度”全然做不到这一点,因此我实现了自己的08调度器。

4.CFS调度

可是我自己的08调度器也没有解决全部问题,原因在于我中了毒,什么毒?“按轮”调度的毒!我思想里面总是有一个Linux的传统观念,那就是全部的进程必须一轮一轮地被调度,每一轮中一个进程仅仅能被调度一次!其实,这样的思想没有什么不好,相反它是避免混乱迎战复杂的最佳方式。关键是你怎么理解它,怎么实现它。
       Linux在2.6.23以后。採用了CFS调度算法。先说一句,它也是“按轮”调度器的实现。


4.1.总览

CFS?怎么说呢?全然公平!

怎么体现啊?先看一张图:

我们应该转换一种思维。将思路切换到虚拟时钟上。对于虚拟时钟而言,每个进程每次执行相等的时间片,系统总是选择虚拟时钟最小的进程执行。效果就是全部进程的虚拟时钟以同样的步进速度你追我赶。这就是平等的体现!每一轮的调度周期中,每个进程都能得到执行机会,执行多久呢?统一都执行N个虚拟时钟周期!

可是怎么相应到真实的时钟呢?答案是按权重来解释这N个虚拟时钟周期!每个虚拟时钟周期的真实时钟周期是:
Tr = T*(Wn/Wbase)
当中,T为时钟嘀嗒间隔,Wn为当前进程的权重,Wbase为參照权重,即nice为0的进程的权重,能够看出。进程权重越大,相应的真实时间越久。那么在随意一个周期T内,一个进程应该执行多少时间呢?非常easy。答案是T*(Wn/Wt)当中Wt为队列的全部进程的权重总和。
       这十分相似我的08调度器!可是比我的精致。难道原因在于它使用了红黑树?...
       在Linux 2.6.23到2.6.25之间,Linux CFS的实现比較朴素,它为每个队列维护了一个fair_clock变量跟踪虚拟时钟的步进,每个task都有一个key。其值为该task当前的虚拟时钟的值,为了保证公平,调度器总是选择虚拟时钟最小的task执行。

这些task存储在红黑树中,当然也能存在堆中。甚至链表中。到了2.6.25内核,CFS实现更直接了,抛弃了队列虚拟时钟的跟踪,而是直接应用T*(Wn/Wt)时间来决定进程n是否在本轮调度已经到期。
       无论怎样,这个CFS调度器还是有一些问题,那就是总的一轮全部进程调度的时间没有被限制。还是会造成饥饿,可是红黑树的插入算法会导致task总是会从右到左移动。尽管会等非常久,但饥饿问题并非持续的(即使用链表也是这样,这是虚拟时钟随风奔跑决定的,不是算法决定的。)。这也是按轮调度的优势吧!


       Linux CFS调度器在UNIXv6的平滑调度的基础上实现了“按轮”调度,实则一朵奇葩。


4.2.睡眠/唤醒

以上描写叙述的CFS调度器是朴素的,没有涉及优先级的提升问题。比方大家都在用的I/O完毕后的优先级提升等。实际上CFS并不关注这些。它仅仅要保证睡眠唤醒后的进程得到比較小的虚拟时钟就可以。

它并不动态改变进程的权重。而是依据其权重计算其虚拟时钟降低多少,CFS总是选择虚拟时钟最小的进程投入执行,如此一来。睡眠唤醒的进程就会领先得到执行机会,得到多少额外的机会就看它的权重了。
       我已经无力关注CFS的细节,细节都是实现问题。假设你理解了原理,细节难道不能够自己实现吗?

5.朴素的UNIX调度器

UNIXv6的调度器是朴素的,超级朴素。能够看到,无论是Linux还是WinNT,都无法超越UNIXv6(其实是BSD4.3+)调度器,它们均採用了各种小技巧。小手段以及额外的诸如平衡器之类的东西。


       最后我想说一下内核抢占。起初的UNIX系统为了保护内核数据而不同意内核抢占,鉴于这仅仅是一种相似锁一样的保护策略。它并非长久的,终于的内核抢占被差点儿全部的现代操作系统实现了。无论是WinNT还是Linux还是现代的UNIX变种。起初,UNIX在实现内核抢占的时候,仅仅是定义了一些内核可抢占的点,即那些没有占有不论什么可能导致相互排斥问题的机要机构的点。可是有一种反其道而行之的思想,即,与其说在不可抢占的内核中定义一些能够抢占的点,不如说在可抢占内核中定义一些不可抢占的点,且后者实现起来更easy。

无论是WinNT还是Linux,还是Solaris。都是这么实现的。
       认为UNIXv6过时的XX们,你们懂CFS吗?你们受过排队之苦吗?朴素的东西永远都只是时。仅仅是你不懂而已...