自Linux 2.6以来(严格说应该是2.5),O(n)调度器被人们认为是一种千年之前就应该抛弃的东西被重重的甩开了,此后出现了O(1),CFS等,再也没人提起O(n)了。说实话,Linux的调度器远比标准Unix的来得复杂,因为Linux被用于不同的场合,从手机一直到大型服务器,跨度如此之大就需要兼各种情况,你既要使网络服务器的吞吐量达到最大,又要使交互体验更佳,然而有时候吞吐量和延迟却是鱼与熊掌的关系...
O(n)被彻底遗忘,某种程度上反映了人们的思维误区,那就是“解决问题的方案随着问题的复杂化而复杂,殊不知方案复杂度的增加很多在于方案本身而不是问题”。走了这么多年,人们已然忘记了一样东西在刚出现时是什么样子了,随着时间的流逝,任何事物都会面目全非,于是人们“站在巨人的肩上”使其越来越复杂,最终崩溃...在路上,或者在崩溃前夕,如果能停下来寻找一下本源,返璞归真的东西也许更好,Con Kolivas研发了BFS调度器,正是循着这样的思路进行的,于是他为自己的调度器起了一个十分具有讽刺意义的名字:Brain Fuck ...
0.小手段
计算机操作系统不是神,它们没有灵魂,然而作为一款通用操作系统,很多设计者都希望其兼顾所有的情况,既要满足桌面交互的低延迟需求,又要满足大吞吐量,同时又要支持N多个处理器,因此就需要做负载均衡...作为一个通用的调度器的设计,为了照顾这么多种情况,你不得不引入一些小巧的手段来专门照顾这些另类的需求。
1.调度器回顾
O(n)调度器
Linux的初始版本使用的是O(n)调度器,它采用单一链表,用遍历比较的方式,采用冒泡算法进行pick-next,下课的原因在于:
a.O(n)中的n吓倒了很多人,大家都不喜欢这个n,因为它的时间复杂度会随着进程数量的增加而增加
b.单一的队列,进程等待时间可能过长,造成饥饿
c.多处理器的出现,为了避免频繁锁定
d.它太简单了?玩弄高深的人不喜欢简单的东西?
O(1)调度器
2.6内核采用了O(1)调度器,该调度器引入了每CPU的优先级队列组,每组队列包含active和expire两个队列,采用启发式算法动态调整优先级,尽可能的进行时间片补偿和惩罚等动态计算,多CPU之间的优先级队列负载均衡。下课的原因有两点:1.启发式算法,补偿/惩罚机制过于复杂,导致算法本身消耗巨大;2.有了更好的CFS调度器。
类“多级反馈队列”调度器
此调度器只以patch的形式存在,在并入mainline之前即被CFS取代。采用类似4.4BSD的传统UNIX调度算法,其优点在于免饥饿以及大吞吐量,这也正是UNIX的特点,缺点在于延迟不稳定,仍然需要一些“小手段”来“玷污”良好的设计。
CFS调度器
该调度器于2.6.23被并入,旨在消除一切的“小手段”,采用完全公平的策略进行调度,引入了虚拟时钟的概念,进程的优先级以及性别(IO?Calc?交互性?)完全映射到虚拟时钟的速率上,基于虚拟时钟的补偿性调度。理论很美,然而为了支持SMP以及交互进程要求日益提高,仍然引入了“小手段”-睡眠补偿,调度域负载均衡等。
接下来的调度器
一定要以一种彻底的方式消除那些“小手段”,“小手段”的引入完全是“兼顾所有情况”这种“鱼与熊掌可兼得”的思想造成的。如果我们回顾上述的调度器,就会发现从O(1)开始,基本都是拆东墙补西墙的方式,解决一个问题从而引入另一个问题。照此以往,事情会越来越复杂的。如果尝试在O(n)调度器的基础上优化,可能会更好,因为O(n)调度器最纯粹。
2.BFS调度器的引入
可配置型调度器的需求
为了避免小手段,那就要彻底抛弃“鱼与熊掌可兼得”的思想,采用“一种调度器只适用于一种场景”的新思路。如此我们可以设计多种调度器,在安装操作系统的时候可以由管理员进行配置,比如我们将其用于桌面,那么就使用“交互调度器”,如果用于路由器,那就使用“大吞吐调度器”...消除了兼顾的要求,调度器设计起来就更佳简单和纯粹了。
面对需要大吞吐量的网络操作系统,我们有传统的UNIX调度器,然而面对日益桌面化的操作系统比如Android手机,我们是否能摒弃那种大而全的调度策略呢?Con Kolivas老大设计出的BFS调度器就是为桌面交互式应用量身打造的。
问题在哪?
Linux 2.6内核实现了那么多的调度器,然而其效果总是有美中不足的地方,到底问题出在哪里?事实上,Linux 2.6的各种调度器的实现都不是完全按照理论完成的,其中都添加了一些小手段。比如虽然CFS号称支持大于2048的CPU个数,然而实际应用中,效果未必好,因为CFS调度器继承了O(1)调度器的load_balance特性,因此在那么多处理器之间进行基于调度域的load_balance,锁定以及独占的代价将会十分大,从而抵消了每CPU队列带来的消除锁定的优势。
总之,这些调度器太复杂了,而且越来越复杂,将80%的精力消耗在了20%的场景中。实际上,做设计不要联想,完全依照我们目前所知道的和所遇到的来,在可用性和效率上被证明是明智的,当然不考虑太多的可扩展性。
回到O(n)调度器
BFS调度器用一句话来总结就是“回到了O(n)调度器”,它在O(n)调度器的基础上进行了优化,而没有引入看起来很好的O(1)调度器,这就是其实质。O(n)调度器有什么不好么?有的。大不了就是遍历的时间太长,BFS根据实际的测试数据忽略之;每个处理器都要锁定整个队列,BFS改之,做到这些既可,这才叫基于O(n)调度器的优化而不是彻底颠覆O(n)调度器而引入O(1)调度器-当然前提是桌面环境。如果说能回到原始的O(n)调度器进行修改使之重新发挥其作用而不是彻底抛弃它,这才是最佳的做法,反之,如果我们把问题的解决方案搞的越来越复杂,最终就是陷入一个泥潭而不可自拔。要知道方案复杂性的积累是一个笛卡儿积式的积累,你必须考虑到每一种排列组合才能,当你做不到这一点的时候,你就需要返璞归真。
BFS调度器的原理
BFS的原理十分简单,其实质正是使用了O(1)调度器中的位图的概念,所有进程被安排到103个queue中,各个进程不是按照优先级而是按照优先级区间被排列到各自所在的区间,每一个区间拥有一个queue
内核在pick-next的时候,按照O(1)调度器的方式首先查找位图中不为0的那个queue,然后在该queue中执行O(n)查找,查找到virtual deadline(如下所述)最小的那个进程投入执行。过程很简单,就像流水一样。之所以规划103个队列而不是一个完全是为了进程按照其性质而分类,这个和每CPU没有任何关系,将进程按照其性质(RT?优先级?)分类而不是按照CPU分类是明智之举。内核中只有一个“103队列”,m个CPU和“103队列”完全是一个“消费者-生产者”的关系。O(1)调度器,内核中拥有m(CPU个数)个“消费者-生产者”的关系,每一个CPU附带一个“生产者(140队列组)”。
只有统一的,单一的“消费者-生产者”的关系才能做到调度的公平,避免了多个关系之间踢皮球现象,这是事实。在结构单一,功能确定且硬件简单的系统中
BFS调度器初始版本的链表的非O(n)遍历
BFS调度器的发展历程中也经历了一个为了优化性能而引入“小手段”的时期,该“小手段”是如此合理,以至于每一个细节都值得品味,现表述如下:
大家都知道,遍历一个链表的时间复杂度是O(n),然而这只是遍历的开销,在BFS调度器中,遍历的目的其实就是pick-next,如果该链表某种意义上是预排序的,那么pick-next的开销可以减少到接近O(1)。BFS如何做到的呢?我们首先看一下virtual deadline的概念
virtual deadline(VD)
VD=jiffies + (prio_ratio * rr_interval)
其中prio_ratio为进程优先级,rr_interval为一个Deadline,表示该进程在最多多久内被调度,链表中的每一个entry代表一个进程,都有一个VD与之相关。VD的存在使得entry在链表的位置得以预排序,这里的预排序指的是vitrual deadline expire的影响下的预排序,BFS和O(n)的差别就在于这个expire,由于这个expire在,一般都会在遍历的途中遇到VD expire,进而不需要O(n)。基于VD的O(n)和基于优先级的O(n)是不同的,其区别在于根据上述的计算公式,VD是单调向前的,而优先级几乎是不怎么变化的,因此基于VD的O(n)调度器某种程度上和基于红黑树的CFS是一样的,VD也正类似于CFS中的虚拟时钟,只是数据结构不同而已,BFS用链表实现,CFS用红黑树实现。
其实,O(n)并没有那么可怕,特别是在桌面环境中,你倒是有多少进程需要调度呢?理论上O(n)会随着进程数量的增加而效率降低,然而桌面环境下实际上没有太多的进程需要被调度,所以采用了BFS而抛弃了诸多小手段的调度器效果会更好些。理论上,CFS或者O(1)可以支持SMP下的诸多进程调度的高效性,然而,桌面环境下,第一,SMP也只是2到4个处理器,进程数也大多不超过1000个,进程在CPU之间蹦来蹦去,很累,何必杀鸡用牛刀呢?瓶颈不是鸡,而是杀鸡的刀,是吧!
pick-next算法
BFS的pick-next算法对于SCHED_ISO进程依照以下的原则进行:
a.依照FIFO原则进行,不再遍历链表
BFS的pick-next算法对于SCHED_NORMAL或者SCHED_IDLEPRIO进程依照以下的原则进行:
a.遍历运行链表,比较每一个entry的VD,找出最小的entry,从链表中删除,投入运行
b.如果发现有entry的VD小于当前的jiffers,则停止遍历,取出该entry,投入运行--小手段
以上的原则可以总结为“最小最负最优先”原则。
作者一席话如下:
BFS has 103 priority queues. 100 of these are dedicated to the static priority
of realtime tasks, and the remaining 3 are, in order of best to worst priority,
SCHED_ISO (isochronous), SCHED_NORMAL, and SCHED_IDLEPRIO (idle priority
scheduling). When a task of these priorities is queued, a bitmap of running
priorities is set showing which of these priorities has tasks waiting for CPU
time. When a CPU is made to reschedule, the lookup for the next task to get
CPU time is performed in the following way:
First the bitmap is checked to see what static priority tasks are queued. If
any realtime priorities are found, the corresponding queue is checked and the
first task listed there is taken (provided CPU affinity is suitable) and lookup
is complete. If the priority corresponds to a SCHED_ISO task, they are also
taken in FIFO order (as they behave like SCHED_RR). If the priority corresponds
to either SCHED_NORMAL or SCHED_IDLEPRIO, then the lookup becomes O(n). At this
stage, every task in the runlist that corresponds to that priority is checked
to see which has the earliest set deadline, and (provided it has suitable CPU
affinity) it is taken off the runqueue and given the CPU. If a task has an
expired deadline, it is taken and the rest of the lookup aborted (as they are
chosen in FIFO order).
Thus, the lookup is O(n) in the worst case only, where n is as described
earlier, as tasks may be chosen before the whole task list is looked over.
使用virtual deadline,类似于CFS的virtual runtime的概念,然而不要红黑树,而采用了双向链表来实现,因为红黑树的插入效率不如链表插入效率,在pick-next算法上虽然红黑树占优势,然而由于VD expire的存在也使得pick-next不再是O(n)了。
BFS初始版本的小手段的意义在于减少O(n)遍历比较时间复杂度带来的恐惧。
去除了小手段的BFS调度器
最终将小手段去除是重要的,否则BFS最终还是会陷入类似O(1),CFS等复杂化的泥潭里面不可自拔,因此在后续的patch中,BFS去除了上述的小手段,用统一的O(n)复杂度来pick-next,毕竟前面已经说了O(n)在特定环境下并不是问题的关键,该patch在2.6.31.14-bfs318-330test.patch中体现。
队列外部执行
BFS调度器和CFS是一样的,都是队列外执行进程的,这样可以减少锁争用带来的性能问题。再列出作者的一席话:
BFS has one single lock protecting the process local data of every task in the
global queue. Thus every insertion, removal and modification of task data in the
global runqueue needs to grab the global lock. However, once a task is taken by
a CPU, the CPU has its own local data copy of the running process' accounting
information which only that CPU accesses and modifies (such as during a
timer tick) thus allowing the accounting data to be updated lockless. Once a
CPU has taken a task to run, it removes it from the global queue. Thus the
global queue only ever has, at most,
(number of tasks requesting cpu time) - (number of logical CPUs) + 1
tasks in the global queue. This value is relevant for the time taken to look up
tasks during scheduling. This will increase if many tasks with CPU affinity set
in their policy to limit which CPUs they're allowed to run on if they outnumber
the number of CPUs. The +1 is because when rescheduling a task, the CPU's
currently running task is put back on the queue. Lookup will be described after
the virtual deadline mechanism is explained.
在schedule核心函数中,使用return_task来把prev进程重新入队,在earliest_deadline_task这个pick-next中,使用take_task将选中的next从队列取出,从而实现队列外执行。
综上的结论
从上面的论述,我们丝毫没有看到有任何的诸如“SMP负载均衡”,“CPU亲和力”,“补偿”,“惩罚”之类的字眼,是的,这些字眼在BFS中完全不需要,BFS也正是摒弃了这些字眼才获得成功的,毕竟在一个一般人使用的桌面操作系统中,没有这么多的套套,大多数人使用的就是一个只有一个到两个处理器核心的系统,难道有必要搞什么调度域么?难道有必要搞什么NUMA么?需求决定一切,面对大型服务器,有UNIX的机制站在那里,而如果我们想把Linux推广到每一个掌上设备,那就没必要复制UNIX的那套了,BFS完全可以完美的搞定一切。小手段的去除,说明BFS调度器的发展方向起码是正确的。
BFS对SMP的支持如何呢?答案是它仅仅支持少量CPU的SMP体系,别忘了BFS的应用场合。因为在调度过程中需要一个遍历所有CPU的O(m)复杂度的计算,这就明确告诉人们,别指望BFS使用在拥有4096个CPU的系统上,正如没人用这种系统看视频一样,那样的话,还是乖乖使用CFS吧。
展示一些代码
BFS调度器思想很简单:集中精力做好一件事,适应一种场景,代码同样十分简单,因此即使贴上代码整个文章也不会显得过于冗长,你再也看不到诸如load_balance或者for_each_domain之类的东西了,至于CPU cache的亲和力智能判断,如果你非要做,那么就自己调用sched_setaffinity系统调用设置吧,把一个线程或者一组相关的进程设置到一个或者一组共享Cache的CPU上,让内核这些,在进程不那么多,CPU个数不那么多,没有NUMA的系统上,真的太累了。
a.进程插入
当有一个进程要插入运行队列的时候,调度器要遍历所有的CPU,看一下是否能抢占某个CPU上正在运行的进程,这种策略是十分合理的。BFS调度器将所有的CPU作为一个执行者,其执行的资源就是唯一的运行队列。如果不能抢占任何进程,那就将唤醒的进程相插入到相应队列的末尾位置,等待deadline的调度:
static inline void enqueue_task(struct task_struct *p)
{
if (idleprio_task(p) && !rt_task(p)) {
if (idleprio_suitable(p))
p->prio = p->normal_prio;
else
p->prio = NORMAL_PRIO;
}
if (iso_task(p) && !rt_task(p)) {
if (isoprio_suitable())
p->prio = p->normal_prio;
else
p->prio = NORMAL_PRIO;
}
__set_bit(p->prio, grq.prio_bitmap);
list_add_tail(&p->run_list, grq.queue + p->prio);
sched_info_queued(p);
}
b.进程唤醒
BFS省略了复杂的负载均衡机制,因此try_to_wake_up瘦身了很多很多。BFS不做调度域负载均衡,其理由是“在决定将进程投入运行的时候,消耗一点点计算来让其自选CPU”。全局的看待整个CPU集合才是王道,所谓的负载均衡并不是说每个CPU上排队待运行进程数量的均衡,而是让每个CPU都动起来。因此唯一需要均衡的地方就是抢占,如果不能抢占任何CPU上的当前进程,那么就排入到全局的BFS队列中,这是个全局的队列而不是每CPU队列,因此对各个CPU机会是均等的,当CPU需要运行一个进程的时候,让其自己来这个全局队列中挑选一个最值得运行的,而在这里,所有的CPU的挑选策略是相同的!
static int try_to_wake_up(struct task_struct *p, unsigned int state)
{
unsigned long flags;
int success = 0;
long old_state;
struct rq *rq;
rq = time_task_grq_lock(p, &flags);
old_state = p->state;
if (!(old_state & state))
goto out_unlock;
if (queued_or_running(p))
goto out_running;
activate_task(p, rq); //调用enqueue_task
try_preempt(p, rq); //这里有一个O(m)时间复杂度的计算,m为cpu的个数
success = 1;
out_running:
trace_sched_wakeup(rq, p, success);
p->state = TASK_RUNNING;
out_unlock:
task_grq_unlock(&flags);
return success;
}
我们没有看到关于任何关于CPU亲和力的“小手段”,在不多于16个CPU的系统上,完全没有必要考虑CPU亲和力带来的巨大性能影响,如今的处理器都是微封装的,大多数的桌面处理器都使用共享Cache,因此为了榨取那么一点点“一级缓存命中”所带来的性能提升而增加如此复杂的针对于CPU亲和力的负载均衡代码,得不偿失!
c.进程调度的pick-next
在BFS中,首先要调度的是抢占的进程,如果有进程抢占其它正在运行的进程,那么将它投入运行:
static inline struct
task_struct *earliest_deadline_task(struct rq *rq, struct task_struct *idle)
{
unsigned long long_deadline, shortest_deadline;
struct task_struct *edt, *p;
unsigned int cpu = rq->cpu;
struct list_head *queue;
int idx = 0;
if (rq->preempt_next) {
//rq->preempt_next在try_preempt中设置
if (likely(task_queued(rq->preempt_next) &&
cpu_isset(cpu, rq->preempt_next->cpus_allowed))) {
edt = rq->preempt_next;
goto out_take;
}
}
retry:
idx = find_next_bit(grq.prio_bitmap, PRIO_LIMIT, idx);
queue = &grq.queue[idx];
if (idx < MAX_RT_PRIO) {
/* We found rt tasks */
list_for_each_entry(p, queue, run_list) {
if (cpu_isset(cpu, p->cpus_allowed)) {
edt = p;
goto out_take;
}
}
/* More rt tasks, we couldn't take the lower prio ones */
++idx;
goto retry;
}
/* No rt tasks, find earliest deadline task */
edt = idle;
if (unlikely(idx >= PRIO_LIMIT)) {
/* All rt tasks but none suitable for this cpu */
goto out;
}
long_deadline = shortest_deadline = longest_deadline() * 2 + 1;
list_for_each_entry(p, queue, run_list) {
unsigned long deadline_diff;
/* Make sure cpu affinity is ok */
if (!cpu_isset(cpu, p->cpus_allowed))
continue;
deadline_diff = p->deadline - jiffies;
/* Normalise all old deadlines and cope with jiffy wrap. */
if (deadline_diff > long_deadline)
deadline_diff = 0;
/* Select the earliest deadline task now */
//简单的冒泡比较
if (edt == idle || deadline_diff < shortest_deadline) {
shortest_deadline = deadline_diff;
edt = p;
}
}
if (edt == idle) {
if (idx < IDLE_PRIO) {
/* Haven't checked for SCHED_IDLEPRIO tasks yet */
idx++;
goto retry;
}
goto out;
}
out_take:
take_task(rq, edt);
out:
return edt;
}
d.时间片用尽
略......
自己的体验
费了九牛二虎之力,编译好了带有BFS的内核(实际上只是在2.6.32内核上打上了一个patch,然后重新编译内核而已,然而这个工作量不小,你可以试试...),基于Debian的桌面版本,硬件是在垃圾场淘的巨老无比的P4,效果十分可观,起码和Mac OS一样了,由此我再也不把RedHat的桌面看作可有可无的部分了。以前我总是编辑/etc/inittab文件将其启动到level 3,然后用ssh登录操作之,现在我也可以直接进level 5了,这就是BFS的效果。顺带说一句,如果你使用很高档或者比较高档的硬件,那么这个效果就不明显了,学子们注意了,别较真儿
听说Android使用了该调度器,我还没有尝试,因为我没有预算买设备。
3.更多的寓意
写这篇文章的目的在于引出一些寓意,而不是介绍BFS调度器本身。该寓意在于,历史得看问题或许会更好。埋没在历史深处的事务并不一定是过时的,它可能是最能反映问题本质的,不要将其置之不顾,时不时地回头看一眼,有的时候,它会给你更多的思路。
所谓的操作系统进程调度就是为了让所有的CPU都动起来,这是一门艺术而不仅仅是一门技术。你既不能让CPU空闲,又不能让某些进程排队等待时间过长,这是一个CPU利用率和对待进程公平性的游戏。这才是问题的本质,而这个问题的本质在不同的硬件配置以及使用场景下又有不同的表现,因此调度器为了迎合这种差异巨大的不同的表现不可能做成一个统一的,只有具体问题具体分析才能得到良好的表现。