如何实现调度
这一问题下分为两个子问题,一个是操作系统作为一个程序,如何重新拥有CPU;另一个是在硬件上如何实现程序与程序之间的切换。
操作系统如何拥有CPU
如果一个进程在CPU上运行,这就意味着操作系统没有运行,如果操作系统没有运行,它怎么能做别的事情,比如进程进程调度?
协作:等待系统调用
程序对硬件的任何操作,都会转交给操作系统代劳,最后程序拿到结果。这一过程称为系统调用(系统调用的细节暂且不提)。
由于程序“主动唤起”了操作系统,所以操作系统重新拥有了CPU,进而能够调度程序。
协作方式能起到调度程序的作用,但是并不是全能的,如果一个程序陷入了无限循环(纯CPU操作),显然操作系统毫无办法。
非协作:时钟中断
现代计算机系统中,计算机拥有时钟设备,时钟设备可以编程为每隔几毫秒发起一次中断,将操作系统重新加载到CPU上。
多CPU的情况下
首先,操作系统因为有许多的功能(或者说许多的功能都属于操作系统,比如文件系统),所以操作系统也有很多线程/进程。上文中主要提到的是操作系统的“进程调度”功能。
在多CPU中,因为不同的操作系统的“进程调度”功能的策略不同,对多CPU的支持也不同,所以有的调度程序只能单线程运行(只能利用一个CPU)。而有的调度程序可以在多CPU上运行良好。
似乎说了半天什么也没说🤦,这里只是提一下,多CPU会让调度程序更加复杂,下面才会具体来谈。
程序在CPU上如何切换
我们以一个系统调用为例,看看CPU是如何和操作系统配合,完成上下文和用户态/内核态的切换的。
我们先来一个总体上的认知,在发生系统调用的时候,发生了什么
- CPU从用户态切换到内核态(有一个特殊的bit位专门标记了这个事情)。
- 保存当前用户程序的状态,包括通用寄存器、指令寄存器、程序计数器等等。
- 彻底切换到内核线程,包括栈、页表地址等等。
- 在内核线程中选择下一个要运行的线程。
- 恢复下一个线程的用户程序的状态。
保存状态
系统调用发生在ecall指令之后。ecall执行,就意味着CPU要开始运行内核代码,但是CPU和内核也不能随意修改当前寄存器的状态,否则就没法恢复原程序了,对吧。所以我们遇到的第一个困难是,怎么在不修改寄存器状态的情况下,开始运行内核代码。
这里CPU(RISC-V指令集)提供了一些硬件支持:SEPC(Supervisor Exception Program Counter)寄存器和STVEC寄存器。
- 指令寄存器:当前运行的程序的指令地址(内核程序或用户程序)
- SEPC寄存器:如果从用户->内核,暂存用户程序指令地址的地方,用于下次恢复用户程序。
- STVEC寄存器:如果从内核->用户,暂存内核程序指令地址的地方,用于下次系统调用的起点。
当执行ecall的时候,硬件会做以下操作:
- ecall将代码从user mode改到supervisor mode。
- ecall将指令寄存器的值暂存在SEPC寄存器。
- ecall将STVEC寄存器的值复制到指令寄存器。
这里还有一个小问题,在用户进程的地址空间中,哪来的内核代码?在程序的虚拟内存中,会有一些高地址的虚拟内存页映射到内核代码,同时这些内存页的PTE的标记位u=0,即不允许用户程序访问。具体而言,在VX中,有以下两个东西
- trampoline page:这里映射着部分内核的指令,比如保存寄存器状态、切换页表、跳转到其他内核代码等等逻辑。
- trapframe page:这里存放着进程特殊的数据,比如上下文切换前寄存器状态等等。
注意上文所说的,CPU的ecall只会将用户程序的指令地址暂存到特殊的寄存器中,通用寄存器中的值都还没存呢。这些值的存储将给了操作系统。具体而言,trampoline page中的逻辑会将所有的用户寄存器状态保存到trapframe page中。在其他的一些实现中,也可以将其存在内核内存的某个位置中。
注意,在这个实现中,保存状态是CPU硬件和操作系统共同工作完成的。而这些工作都在ecall和trampoline page的开始处完成。
切换到内核线程
其实在ecall的时候,CPU就通过SEPC寄存器,将代码执行的位置从用户代码转到了内核代码。
在ecall和trampoline page共同保存用户线程上下文后,并切换到内核页表时,有一个知识点:
这里和操作系统第一次设置页表地址一样,都很重要。问题和解决方案也是类似的,都要求在切换前后,翻译后的地址都完全相同。所以在内核程序的页表项和在用户程序的页表项的内容是完全一样的。
切换完成之后,还会创建或者找到一个内核栈,并跳转到内核中其他的代码。
切换到用户线程
切换到用户线程主要的工作就是恢复用户线程的状态,并为下一次用户态 -> 内核态做好准备。具体包括
-
恢复用户线程上下文
-
设置STVEC寄存器指向trampoline代码
-
在trapframe中存储:
- 存储了kernel page table的指针
- 存储了当前用户进程的kernel stack
- 存储了usertrap函数的指针,这样trampoline代码才能跳转到这个函数
- 从tp寄存器中读取当前的CPU核编号,并存储在trapframe中,这样trampoline代码才能恢复这个数字,因为用户代码可能会修改这个数字
小结
ecall -> 汇编uservec() 保存用户线程状态,并切换page table,到达内核态
uservec() —> usertrap() 保存另外一些没保存的用户线程状态,根据特定原因(时间片到了、IO中断、系统调用等),执行特定步骤
usertrap() —> syscall() -> ... 由于是系统调用,执行特定的系统调用,并将用户的程序计数器向后推4位(跳过ecall,执行下一个指令)
usertrap() —> yield() 由于是时间片结束,修改该proc状态,调度该线程
yield() —> sched() 各种检查
sched() -> 汇编swtch() 保存当前线程的内核线程上下文,并恢复当前CPU的调度器scheduler的上下文
汇编swtch() -> scheduler() 选择下一个要运行的线程,并对其使用
scheduler() -> 汇编swtch() 保存当前CPU调度器scheduler的上下文,并恢复内核线程(大概率是另一个用户线程的)上下文
汇编swtch() -> sched() -> ... (另一个)内核线程从sched函数中返回,并接着往下走
usertrap() -> usertrapret() 准备好恢复用户线程运行时所需要数据
usertrapret() -> 汇编userret() 恢复用户线程状态,并切换page table,到达用户态
上述是一个更加通用的例子,那么实际上有些特殊case,可以优化整个过程。
-
在以下三种情况下,或许操作系统可以在不切换page table的前提下完成部分系统调用。切换page table的代价确实是非常高的。
- 一个系统调用很简单
- 操作系统全部使用物理地址,而不进行虚拟化
- 将user和kernel的虚拟地址映射到一个page table中(这样每个用户页表都包含了大量的内核页表)
-
或许在一些系统调用过程中,不需要保存全部的寄存器。具体要不要保存,可能取决于软件、编程语言、编译器等等。但是总之,如果不需要保存全部寄存器,可以省下大量的时间。
-
最后,对于某些简单的系统调用或许根本就不需要任何stack,所以对于一些非常关注性能的操作系统,ecall不会自动为你完成stack切换是极好的。
注意,这里屏蔽了ecall如何指明要执行那一个系统调用,如何从用户态向内核态传递参数等等细节。但是这些都是通过约定特定寄存器来实现的。
调度的指标
周转与响应
周转时间(Turnaround Time)
换个角度说
响应时间(Response Time)
换个角度说
周转和响应的矛盾
对于这两个指标,都有简单而直接的最佳实现。
一类是SJF(Shortest Job First,最短任务优先)和STCF(Shortest Time-to-Completion First,最短完成时间优先),这种策略总是可以优化周转时间,但是对响应时间不利。
另一类是RR(Round-Robin,轮转),这种策略可以优化响应时间,但是对周转时间不利。
以上两类策略还没考虑IO时间,而且第一类策略还要求作业的运行时间已知(至少在计算机领域这个前提还很难满足)。
单CPU下调度策略
在有IO、任务完成时间未知、既不让长耗时工作饥饿,又要照顾交互体验的要求下,现代化操作系统有这么几种CPU调度策略。
MLFQ
MLFQ(Multi-level Feedback Queue,多级反馈队列)。其基本思路是设置许多独立的队列(queue)、每个队列有不同的优先级(priority level)。操作系统优先执行优先级较高的队列中的任务,对于同一队列中的多个任务采用轮转调度。
由此得到两个基本规则
- 如果 任务A的优先级 > 任务B的优先级,运行任务A
- 如果 任务A的优先级 = 任务B的优先级,轮转运行A和B
MLFQ的关键在于如何调整优先级,一般而言,我们都会根据应用的行为而去调整它的优先级,这是我们接下来要详细讨论的部分。
慢慢降低优先级
首先,我们有一些先验经验:如果一个程序频繁放弃CPU,则可能是优先级比较高、运行时间较短的交互性工作;反之,则可能是需要很多CPU时间的计算密集型工作。
其次,我们时常要同时照顾长工作和短工作,让短工作早点运行完能有效减少周转时间。我们可以做出简单的假设:每个新来的工作都是短工作,都放在高优先级。如果真的是短任务,则MLFQ近似于SJF,有良好的周转时间。如果是长任务,则会将优先级降低。
由此,我们可以提出以下补充规则。
- 任务进入系统时,放在最高优先级队列
-
- 工作用完整个时间片后,降低其优先级(放在下一队列)
- 如果工作在其时间片内主动释放CPU,则优先级不变。
看起来不错,事实上有三个问题
首先,会有饥饿问题。如果系统有足够多的交互性工作,则一些长任务永远无法得到CPU,进而被饿死。我们希望所有的任务多少都能有些进展。
其次,程序可以愚弄调度程序(game the scheduler)。比如程序永远在时间片用完之前,主动放弃CPU(比如发起一个小IO)。这样程序可以永远在高优先级。
最后,一个程序可能会在不同的时间有不同的表现,比如一开始是CPU密集型,然后是交互密集型。目前的策略对此毫无办法。
提升优先级
对于上面讲到的饥饿问题和程序不同时间的不同表现问题,一个简单的策略是周期性地提高所有任务的优先级。所以我们补充一条规则
- 每经过一段时间S,则将系统中所有工作重新加入最高优先级队列。
但是这个S的设置就变得很重要,而这个值事实上很难设置好。
放弃先验经验
规则5依旧没能解决程序可以愚弄调度程序的问题。其根本原因是我们的先验经验不是那么完美。因此我们换一种方式,重写规则4。
- 一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级。
配置
MLFQ几个大规则的讨论基本结束了,接下来是算法具体实现时,一些关键变量的问题。比如配置多少队列?每一层对列的时间片有多大?多久提升一次程序的优先级?
有些实现会通过便捷的配置方式来配置这些变量。而有时候会使用一些公式来调整这些变量。
小结
MLFQ的关键是不需要有任务先验知识。目前主要的系统中Windows在用MLFQ。
- 如果 任务A的优先级 > 任务B的优先级,运行任务A
- 如果 任务A的优先级 = 任务B的优先级,轮转运行A和B
- 任务进入系统时,放在最高优先级队列
- 一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级。
- 每经过一段时间S,则将系统中所有工作重新加入最高优先级队列。
彩票调度
比例份额(proportional-share)有时候也称为公平份额(fair-share),其有一个方便理解的现代化例子:每隔一段时间,都会举行一次彩票抽奖,以确定接下来应该运行哪个进程。如果进程拥有更多的彩票,则被运行的概率应该更大。
份额机制还很容易衍生出来其他一些很有用的机制,比如彩票货币机制(比如操作系统给不同的用户不同的份额,用户自由分配,然后再转换交给操作系统做转换),彩票转让机制(一个进程可以将自己的份额转让给别的进程),彩票通胀(在信任环境下,进程可以自己提升自己的彩票数,不需要和其他进程或操作系统沟通)。
但是彩票调度最大的问题是,如何分配彩票?这个最大的问题没有什么合适的策略。
目前按彩票调度只有在部分领域里有用,比如虚拟数据中心,VMWare等。
步长调度
彩票调度是通过不确定算法(随机的彩票数)来选择一个进程运行。步长调度是彩票调度的确定性版本。具体内容pass。
多CPU下调度策略
小对比
注意,单对列调度和多对列调度是两种调度风格,是一种调度策略的分类依据,而不是实际的调度算法。在实际的调度算法上,还有一种O(1)的调度算法(已被遗弃),本文在CFS的起因中提到一点。
单对列调度风格的调度程序必然需要锁来保证在遍历进程队列(或者其他数据结构,但这里重点在于遍历这个动作)时,只会存在一个线程,因此“理论上”对多CPU没有那么好的支持,然而由于“事实上”我们电脑的CPU数很少,争抢锁并不是大问题。而多队列调度风格的调度程序“理论上”天然更具有可扩展性,但是由于负载不均(load imbalance)而导致的负载均衡(load balance)操作会消耗CPU,所以“事实上”不一定表现非常好。
PS:下文中反复出现的“进程”,事实上应该是“调度单位”。但是进程更加顺嘴一点,因此都写成进程了:)
PS:CFS和BFS在优先级的划分上有些相似之处,比如都划分出了实时进程这一优先级,而且对于这一优先级的进程处理都有相似之处。两者更加显著的区别在于普通进程上。
单对列调度 e.g. BFS
起因
BFS(Brain Fuck Scheduler),事实上BFS的诞生要晚于多对列调度策略CFS(因此在阅读上,或许你应该先读CFS),其避免了CFS一些过于极客、花里胡哨的想法,从而开发出了一个更加实用而简单的调度策略。
漫画讲的是,Linux花费了很多精力,将支持的CPU的上限从1024提升到了4096,而另一个小人问Linux是否支持流畅的全屏Flash视频播放,另一个人回答道,不支持,但是谁有这种需求?
漫话讽刺的是Linux系统不贴近用户需求的问题。因此面对个人电脑和手机,重新设计了一套专门在小数量CPU下运行良好的系统,其就是BFS。
BFS的优先级划分
BFS将所有进程分成 4 类,分别表示不同的调度优先级:
- Realtime,实时进程
- SCHED_ISO,isochronous进程,用于交互式任务
- SCHED_NORMAL,普通进程
- SCHED_IDELPRO,低优先级任务
实时进程总能获得CPU,采用Round Robin或者FIFO的方法来选择同样优先级的实时进程。他们需要superuser的权限,通常限于那些占用 CPU 时间不多却非常在乎 Latency 的进程。
SCHED_ISO进程适用于对交互性要求比较高的,又无法成为实时进程的进程,这些进程能够抢占SCHED_NORMAL进程。此外当SCHED_ISO进程占用CPU时间达到一定限度后,会被降级为SCHED_NORMAL,防止其独占整个系统资源。
SCHED_NORMAL类似于CFS中的SCHED_OTHER,是基本的分时调度策略。
SCHED_IDELPRO类似于CFS中的SCHED_IDLE,即只有当CPU即将处于IDLE状态时才被调度的进程。
在这些不同的调度策略中,实时进程分成 100 个不同的优先级,加上其他三个优先级,一共有103个不同的优先级。同一优先级下,可能会有多个进程处于ready状态。所以对于每个类型,还需要一个队列来存储属于该类型的ready进程。
BFS用103位的bitmap来表示是否有相应类型的进程准备进行调度。如下图所示:
如图,Realtime优先级内,第三优先级有进程ready,同时SCHED_NORMAL优先级有进程有ready。下面的queues里记录着对应优先级有哪些进程ready了。
时间片和VDDL
当一个进程被创建时,它被赋予一个固定的时间片和一个Virtual Deadline。
如果一个进程主动sleep,则时间片减去一部分,Virtual Deadline保持不变。如果进程时间片用完,则时间片刷新,Virtual Deadline刷新。
该虚拟 deadline 的计算公式非常简单:
其中Now是当前时间 , User Priority 是进程的优先级,Round Robin Interval近似于一个进程必须被调度的最后期限,所谓Deadline么。不过在这个Deadline之前还有一个形容词为Virtual,因此这个Deadline只是表达一种愿望而已,并非很多领导们常说的那种deadline。
调度规则
调度器如何选择下一个被调度的进程的问题被称为Task Selection或者pick next。
当调度器决定进行进程调度的时候,BFS 将按照下面的原则来进行任务的选择:
首先顺着Bitmap寻找第一个Ready的进程优先级,如图是SCHED_NORMAL。- 遍历对应进程队列,寻找Virtual Deadline最小的进程。如图,第二个进程的Virtual Deadline是5,是队列中最小的一个
- 上一步操作是O(n)的,但是事实上并不需要全部遍历,因为还有一个补充规则:当某个进程的Virtual Deadline小于当前的Now值时,直接返回该进程。
调度场景
-
进程 wakeup:Task Insertion
- 先查看当前进程是否可以抢占当前正在系统中运行的进程。因此它会用新进程的virtual deadline值和当前在每个CPU上正在运行的进程的virtual deadline 值进行比较,如果新进程的值小,则直接抢占该 CPU 上正在运行的进程。
- 如果抢占失败,调度器需要执行 task insertion 的操作,将该进程插入到 run queue 中。
第一步操作时O(n)的,n是CPU数量。第二步操作时O(1)的。虽然有O(n)的时间复杂度,但是这个设计保证了非常好的 low-latency 特性。
-
进程 Sleep
当前正在运行的进程有可能主动睡眠,此时,调度器需要将该进程从 run queue 中移除,并选择另外一个进程运行。但该进程的 virtual deadline 的值保持不变。
-
进程用完自己的时间片
每个进程都拥有自己的时间片,即使不被其他进程抢占,假如属于自己的时间片用完时,当前进程也一定会被剥夺 CPU 时间,以便让别的进程有机会执行。
当前进程的时间片用完后就必须让出 CPU, 此时将它的 virtual deadline 按照公式一重新计算。
这保证了一个特性:只有其他就绪进程都获得 CPU 之后,用完当前时间片的进程才可以再次得到运行,这避免了饥饿。
多队列调度 e.g. CFS
起因
CFS (Completely Fair Scheduler)发展在O(1)调度算法之后,O(1)的本质确实非常简单,就是两个队列,然后从前一个队列里顺序取进程运行。但是由于不断加入的启发性功能(来调整进程在队列里的位置),导致了复杂度不断上升,性能也不断下降。CFS摒弃了这些启发性功能,希望从本质上保证整个进程都被平等调度到。
但是实际上由于进程会主动Sleep(IO、主动sleep等情况),所以在一个调度周期内,有的进程可能不会运行满其运行时间。操作系统就会在下一个周期内提高其优先级,让他拥有更多的运行时间。
调度周期
调度周期很好理解,就是将所有处于TASK_RUNNING态进程都调度一遍的时间。如果调度周期为10ms,如果有10个进程,则每个进程运行1ms;如果有100个进程,则每个进程运行0.1ms。可见如果调度周期保持不变,进程越多,平均花费在进程切换上的时间就越多。因此调度周期是动态变化的,计算规则如下:
如果nr_running(就绪进程的数量)大于sched_nr_latency,则
如果没有超过,则
sysctl_sched_min_granularity是调度的最小粒度,可以理解成每个程序的时间片,默认为0.75ms。sysctl_sched_latency是默认的调度周期,默认为6ms。(两个默认值我没有考证,但是大概是这么个意思:)
权重与nice
CFS调度器针对优先级提出了nice值的概念,值范围是[-20, 19],数值越小代表优先级越大,同时也意味着权重值越大,nice值和权重之间可以互相转换。内核提供了一个表格转换nice值和权重。
const int sched_prio_to_weight[40] = {
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548, 7620, 6100, 4904, 3906,
/* -5 */ 3121, 2501, 1991, 1586, 1277,
/* 0 */ 1024, 820, 655, 526, 423,
/* 5 */ 335, 272, 215, 172, 137,
/* 10 */ 110, 87, 70, 56, 45,
/* 15 */ 36, 29, 23, 18, 15,
};
NICE_0_LOAD指的就是nice值0对应的权重,其值为1024。
PS:我不是很理解为啥要做nice到weight的转换,直接存weight应该也行。似乎是为了减少运算,通过存一个const,可以直接获取值而避免计算。还有一个sched_prio_to_wmult也是一个长度为40的数组,也是通过nice直接转换的。
虚拟时间
我们先看另一个概念,一个进程在一个调度周期内,应该获得什么多大的时间片。
由此可见,所有进程在一个调度周期内,并不满足“公平”这个原则。事实上,一个周期内的“不公平”,是为了在多个周期内总体保持“公平”。而“公平”的衡量标准,就是虚拟时间。
有点眼花,来个中文版的
虚拟时间 = 进程实际总运行时间 \times \frac{NICE_0_LOAD}{进程权重}
如果我们将运行时间带入进程实际总运行时间,则可以得到
虚拟时间 = 调度周期 \times \frac{进程权重}{所有进程权重之和} \times \frac{NICE_0_LOAD}{进程权重}
化简为
虚拟时间 = 调度周期 \times \frac{NICE_0_LOAD}{所有进程权重之和}
由此可见,如果没有IO,没有Sleep,则所有进程的虚拟时间都应该一样,与权重无关。如果一个进程的虚拟时间太短,说明其收到了不公平的对待,则优先运行,增大其实际运行时间,增大其虚拟时间。以保证公平。
顺着这个调度思路,我们也可以简单推理出来这个算法合适的数据结构:优先队列或红黑树(实际上使用的是红黑树)。
load balance
我休息一休息,不写了嘤嘤嘤,似乎这一篇写的还行,我还没细看。
参考资料
《操作系统导论》