调度器是非常复杂的话题,尤其是 CFS 调度器,想要描述清楚,需要一支非凡的笔,我还没有找到。但 BFS 非常简单,所以我才有勇气在这里写点儿 BFS 的实现原理什么的。首先介绍几个关键概念。
当一个进程被创建时,它被赋予一个固定的时间片,和一个虚拟 Deadline。该虚拟 deadline 的计算公式非常简单:
Virtual Deadline = jiffies + (user_priority * rr_interval) 公式一
其中 jiffies 是当前时间 , user_priority 是进程的优先级,rr_interval 代表 round-robin interval,近似于一个进程必须被调度的最后期限,所谓 Deadline 么。不过在这个 Deadline 之前还有一个形容词为 Virtual,因此这个 Deadline 只是表达一种愿望而已,并非很多领导们常说的那种 deadline。
虚拟 Deadline 将用于调度器的 picknext 决策,这将在后续章节详细描述。
在操作系统内部,所有的 Ready 进程都被存放在进程队列中,调度器从进程队列中选取下一个被调度的进程。因此如何设计进程队列是我们研究调度器的一个重要话题。BFS 采用了非常传统的进程队列表示方法,即 bitmap 加 queue。
BFS 将所有进程分成 4 类,分别表示不同的调度策略 :
实时进程总能获得 CPU,采用 Round Robin 或者 FIFO 的方法来选择同样优先级的实时进程。他们需要 superuser 的权限,通常限于那些占用 CPU 时间不多却非常在乎 Latency 的进程。
SCHED_ISO 在主流内核中至今仍未实现,Con 早在 2003 年就提出了这个 patch,但一直无法进入主流内核,这种调度策略是为了那些 near-realtime 的进程设计的。如前所述,实时进程需要用户有 superuser 的权限,这类进程能够独占 CPU,因此只有很少的进程可以被配置为实时进程。对于那些对交互性要求比较高的,又无法成为实时进程的进程,BFS 将采用 SCHED_ISO,这些进程能够抢占 SCHED_NORMAL 进程。他们的优先级比 SCHED_NORMAL 高,但又低于实时进程。此外当 SCHED_ISO 进程占用 CPU 时间达到一定限度后,会被降级为 SCHED_NORMAL,防止其独占整个系统资源。
SCHED_NORMAL 类似于主流调度器 CFS 中的 SCHED_OTHER,是基本的分时调度策略。
SCHED_IDELPRO 类似于 CFS 中的 SCHED_IDLE,即只有当 CPU 即将处于 IDLE 状态时才被调度的进程。
在这些不同的调度策略中,实时进程分成 100 个不同的优先级,加上其他三个调度策略,一共有 103 个不同的进程类型。对于每个进程类型,系统中都有可能有多个进程同时 Ready,比如很可能有两个优先级为 10 的 RT 进程同时 Ready,所以对于每个类型,还需要一个队列来存储属于该类型的 ready 进程。
BFS 用 103 个 bitmap 来表示是否有相应类型的进程准备进行调度。如下图所示:
当任何一种类型的进程队列非空时,即存在 Ready 进程时,相应的 bitmap 位被设置为 1。
调度器如何在这样一个 bitmap 加 queue 的复杂结构中选择下一个被调度的进程的问题被称为 Task Selection 或者 pick next。
当调度器决定进行进程调度的时候,BFS 将按照下面的原则来进行任务的选择:
首先查看 bitmap 是否有置位的比特。比如上图,对应于 SCHED_NORMAL 的 bit 被置位,表明有类型为 SCHED_NORMAL 的进程 ready。如果有 SCHED_ISO 或者 RT task 的比特被置位,则优先处理他们。
选定了相应的 bit 位之后,便需要遍历其相应的子队列。假如是一个 RT 进程的子队列,则选取其中的第一个进程。如果是其他的队列,那么就采用 EEVDF 算法来选取合适的进程。
EEVDF,即 earliest eligible virtual deadline first。BFS 将遍历该子队列,一个双向列表,比较队列中的每一个进程的 Virtual Deadline 值,找到最小的那个。最坏情况下,这是一个 O(n) 的算法,即需要遍历整个双向列表,假如其中有 n 个进程,就需要进行 n 此读取和比较。
但实际上,往往不需要遍历整个 n 个进程,这是因为 BFS 还有这样一个搜索条件:
当某个进程的 Virtual Deadline 小于当前的 jiffies 值时,直接返回该进程。并将其从就绪队列中删除,下次再 insert 时会放到队列的尾部,从而保证每个进程都有可能被选中,而不会出现饥饿现象。
这条规则对应于这样一种情况,即进程已经睡眠了比较长的时间,以至于已经睡过了它的 Virtual Deadline,如下图所示:
T1 本来的 virtual deadline 为 t1,它 sleep 之后,其他的进程比如 T2 开始运行,等到 T1 再次 wakeup 的时候,当时的 jiffies 已经大于 t1,在这种情况下,T1 无需和其他进程的 virtual deadline 相比较,而直接被 BFS 调度器选取。
三个基本的 scenario 可以概括多数的调度情景。系统中发生的每一次调度都属于以下三种情景之一。
睡眠进程 wakeup 时,调度器需要执行 task insertion 的操作,将该进程插入到 run queue 中。BFS 将进程插入相应队列的操作就是执行一个双向队列的插入操作,计算机常用算法结构告诉我们,这个操作是 O(1) 的。不过,BFS 在执行插入操作之前需要首先查看当前进程是否可以抢占当前正在系统中运行的进程。因此它会用新进程的 virtual deadline 值和当前在每个 CPU 上正在运行的进程的 virtual deadline 值进行比较,如果新进程的值小,则直接抢占该 CPU 上正在运行的进程。这个算法是 O(m) 的,其中 m 是 CPU 的个数,假如系统中有 16 个 CPU,那么每次都需要进行 16 次比较。但这个设计却保证了非常好的 low-latency 特性。
当前正在运行的进程有可能主动睡眠,此时,调度器需要将该进程从 run queue 中移除,并选择另外一个进程运行。但该进程的 virtual deadline 的值保持不变。
这样该进程 wakeup 时,其 virtual deadline 将相对较小,因为 jiffies 随着时间流逝而不断增加。较小的 Virtual Deadline 可以保证该进程能更快得到调度。
仍然以图 8 为例,系统中有两个进程,T1 和 T2,T1 进入 sleep 状态后其 virtual deadline 仍然为 t1。T2 此时被调度,根据公式一,计算得出其 virtual deadline 为 t2。此后,T1 进程 wakeup 了,此时虽然 T2 的时间片尚未用完,但由于 T1 的 virtual deadline 小于 T2 的,(t1<t2),因此 T1 立即得到调度。
每个进程都拥有自己的时间片,即使不被其他进程抢占,假如属于自己的时间片用完时,当前进程也一定会被剥夺 CPU 时间,以便让别的进程有机会执行。
当前进程的时间片用完后就必须让出 CPU, 此时将它的 virtual deadline 按照公式一重新计算。
这保证了一个特性:只有其他就绪进程都获得 CPU 之后,用完当前时间片的进程才可以再次得到运行,这避免了饥饿。
在 Linux 内核进入 2.6 时,调度器采用 per cpu run queue 从而克服了单一 run queue 的局限。在多 CPU 系统中,单一 run queue 意味着 run queue 成为了系统的瓶颈,因为在同一时刻,一个 CPU 访问 run queue 时,其他的 CPU 即使空闲也必须等待。当使用 per CPU 的 run queue 之后,每个 CPU 不必再使用大锁,从而能够并行地处理调度。
但很多事情都不像第一眼看上去那样简单。
Kolivas 发现,采用 per cpu run queue 所带来的好处会被追求公平性的 load balance 代码所抵消。在目前的 CFS 调度器中,每颗 CPU 只维护本地 run queue 中所有进程的公平性,为了实现跨 CPU 的调度公平性,CFS 必须定时进行 load balance,将一些进程从繁忙的 CPU 的 run queue 中移到其他空闲的 run queue 中。
这个 load balance 的过程需要获得其他 run queue 的锁,这种操作降低了多运行队列带来的并行性。
并且在复杂情况下,这种因 load balance 而引入的 footprint 将非常可观。
当然,load balance 引入的加锁操作依然比全局锁的代价要低,这种代价差异随着 CPU 个数的增加而更加显著。但请您注意,BFS 并不打算为那些拥有 1024 个 CPU 的系统工作,假若系统中的 CPU 个数有限时,多 run queue 的优势便不明显了。
而 BFS 采用单一队列之后,每一个需要调度的新进程都可以在全局范围内查找最合适的 CPU,而无需 CFS 那样等待 load balance 代码来决定,这减少了多 CPU 之间裁决的延迟,最终的结果是更小的调度延迟