进程调度决定了哪个进程什么时候运行,运行多久。调度应最大程度利用系统,让用户觉得很多进程确实是同时在执行。
多线程
现代Linux系统可以同时在内存里有多个进程,但只有一个是在可运行状态的。两种多线程方式:
- 抢占型多线程:非自愿暂定正在运行的进程。一个进程在被抢占前能运行的时间叫时间切片,调度可以根据管理时间切片来为系统全局做出调度决策。也可防止一个进程独占处理器。
- 合作型多线程:正在运行的进程只有自己想停止时停止,这个行为叫做yielding。此时调度器无法做全局的调度决策,也无法阻止一个进程独占处理器。
Unix自问世以来就采用的是抢占型多线程。
Linux进程调度
- O(1) Scheduler:是没有交互的大型服务器的理想选择,但在交互应用十分常见的台式机系统上的性能低于同等水平。
- Rotating Staircase Deadline:借鉴队列理论,引入公平调度的概念。
- Completely Fair Scheduler (CFS):O(1) scheduler在内核版本2.6.23中的最终替换。
调度策略
I/O-Bound VS Processor-Bound
I/O-Bound | Processor-Bound |
---|---|
花很多时间在提交和等待I/O请求 | 花很多时间执行代码 |
每次运行时间短 | 运行次数少但每次运行时间长 |
例如:GUI应用 | 例如:无限循环、sshkeygen、MATLAB |
这两个类别并不是完全互斥的,一个进程可能同时拥有这两类行为,例如X Window服务器。
系统中的调度策略必须尝试满足两个相互矛盾的目标:快速的进程响应时间(低延迟)和最大的系统利用率(高吞吐量)。Linux旨在提供良好的交互响应和桌面性能,针对进程响应(低延迟)进行了优化,因此,Linux更青睐I/O-Bound进程。
进程优先级
调度算法的一种常见类型是基于优先级的调度。一般算法的核心思想是优先级较高的进程先于优先级较低的进程运行,而优先级相同的进程则轮循运行。除此之外,一般高优先级的进程拥有更长的时间切片。
Linux内核拥有两种优先级范围。在第一个范围,即-20到19之间(默认为0)的值叫做nice value,nice value越大优先级越低。可以通过命令ps -el
查看系统里的进程和对应的nice value(NI列)。第二个范围是可配置的(默认是0-99),这个范围的优先级叫做real-time priority。这和nice value不同的是,real-time priority数值越高优先级越高。所有实时进程的优先级都高于普通进程。 也就是说,real-time priority和nice value位于不相交的值空间中。可以通过命令ps -eo state,uid,pid,ppid,rtprio,time,comm.
查看系统里的进程和对应的real-time priority(RTPRIO列)。若值为“-”,则表示该进程不是实时的。
时间切片
调度器需要设定一个默认的时间切片,如果这个值太大,系统交互性能就会很差;如果这个值太小,会有大量的时间浪费在进程切换上。而且I/O-Bound进程不需要很长的时间切片,而Processor-Bound需要较长的时间切片,这些都是相互矛盾的目标。在Linux的CFS调度中,并不会直接给进程分配一个时间切片,而是分配一个处理器的使用占比。这个百分比又进一步由进程的nice value决定,nice value就类似于权重,更改每个进程接收的处理器时间的比例。
在Linux新的CFS调度中,调度决策取决于新的可运行的进程已经消耗多少处理器比例。 如果消耗的比重比当前执行的进程少,它将立即运行,抢占当前进程。 如果不是,则被安排在以后运行。
实施中的调度策略
假定一个系统中有两个可运行进程:文字编辑器和视频编码器。显然,文字编辑器是I/O-Bound而视频编码器是Processor-Bound。我们对文字编辑器的调度目标是:
- 它在需要处理器的时候总是有可用的处理器时间。
- 一旦编辑器被唤醒,能够抢占视频编码器。
在Linux中,通过保证文本编辑器具有特定比例的处理器来达到这两个目标。如果视频编码器和文本编辑器是唯一运行的进程,并且两者nice value值相等,则该比例为50%。实际运行时,文本编辑器大部分时间都在等待用户按下按键,处于阻塞状态,所以它不会占用处理器的50%。这时视频编码器就可以使用超过其分配的50%,快速完成编码。当文本编辑器被唤醒时,CFS注意到它虽然被分配了50%,实际使用却少得多。由于文本编辑器的运行时间少于视频编码器,CFS允许文本编辑器抢占视频编码器,使之运行。文本编辑器快速处理用户的按键后,再次休眠等待更多输入。这就保证了CFS始终允许文本编辑器在需要时运行,而视频编码器在其余时间运行。
Linux调度算法
调度器类
Linux调度器是模块化的,允许不同的算法调度不同类型的进程,这种模块化称为调度器类。 调度器类使不同的可插入算法可以共存,从而调度自己类型的进程。每一个调度器类都有一个优先级,高优先级的调度器类可以从中选一个进程下一个运行。CFS调度器类主要用于是普通进程的调度,该类在Linux中被称为SCHED_NORMAL。
Unix系统的进程调度
在Unix系统,优先级是以nice value导出至用户态。会存在一些问题:
- 将nice value映射到时间切片时要决定对每个nice value分配多少绝对的时间切片,这会导致一个次优的切换,在很多情况下是不理想的。
- 不同nice value的相对值和对应的时间切片的相对值会有很大的差异。nice value下降一,时间切片的上升值会根据起始nice value变动很大。(nice value:1 -> 0,timeslice:95 -> 100;nice value:19 -> 18,timeslice:5 -> 10)
- 如果时间切片是绝对值,那必须是内核能测量的时间值。这就导致了最小时间切片有下限,是timer tick的周期;另外,两个时间切片的差值也必须是最小可测量时间的倍数;而且,不同的timer tick,时间切片也得对应变化。
- 如果是基于优先级的调度器,若想提高交互性能,可能会在进程被唤醒时提高它的优先级来让它能立刻运行。这可能会以系统剩余部分为代价,给这个进程不公平的处理器时间。
很多针对以上问题的解决办法都没有解决真正的问题:分配绝对时间切片会产生恒定的切换频率,但公平性差异很大。而Linux中CFS彻底抛弃时间切片,给每个进程分配处理器的使用占比,这就产生了恒定的公平性,不同的切换频率。
公平调度
Perfect Multitasking:假设n是可运行的进程数,每个进程可占用处理器1/n的时间;能在无限小的时间范围内调度。这样在可测量的时间周期里,就好像同时运行了n个进程。
实际情况下,CFS会考虑switching cost,调度策略如下:
- 每个进程都会分配一些时间,轮循选择当前运行时间最少的为下一个进程。
- CFS根据可运行进程数计算每个进程初始的处理器占比。
- nice value作为权重影响进程的处理器占比,nice value高的最后权重低,最终占比也少。
targeted latency:CFS设定的尽可能接近“无限小”的时间范围。这个值越小,交互性更好,意味着更接近perfect multitasking,但切换代价高昂,最终性能低下。
minimum granularity:CFS给每个进程设定的时间切片下限。即使进程数一直在增长,每个进程的运行时间也不会低于此值。
不同的nice value,nice value的差值一样,则处理器占比分配也一样,只有nice value的相对值会影响处理器占比的分配。
CFS之所以称为公平调度程序,是因为它为每个进程分配了公平的处理器使用占比,但不是绝对公平,因为只是尽可能去靠近perfect multitasking。
Linux调度实现
Time Accounting
The Scheduler Entity Structure
CFS用Scheduler Entity Structure,数据结构是struct sched_entity来记录进程运行时间。这个结构是存在process descriptor中,在数据结构task_struct中有一个字段叫se,就是存放它的。
虚拟运行时间
vruntime变量存储经过进程数归一化或加权后的实际运行时间,用于计算该进程已经运行多久了,还有多久能运行结束。update_curr()
用于计算当前进程的运行时间,并将实际运行时间存储在变量delta_exec中,该值作为参数传递给__update_curr()
,计算加权后的虚拟运行时间。系统会周期调用update_curr()
,不管进程处于运行还是阻塞状态。因此,vruntime可以说能精确衡量一个进程的运行时间,也可以指示哪个进程接下来运行。
Process Selection
CFS调度的核心算法就是选择vruntime最小的进程作为下一个进程。CFS用红黑树来存储可运行的进程,可以快速找到vruntime最小的那个进程。
选择下一个进程
在上章提到的红黑树,每个结点的key都是该进程的vruntime,获取vruntime最小的进程就是找到红黑树最左的叶子结点,由函数__pick_next_entity()
实现:
static struct sched_entity *__pick_next_entity(struct cfs_rq *cfs_rq) {
struct rb_node *left = cfs_rq -> rb_leftmost;
if (!left)
return NULL;
return rb_entry(left, struct sched_entity, run_node);
}
从代码中可以看到函数并没有真的去遍历树找到最左结点,而是由变量rb_leftmost缓存。返回的即接下来要运行的程序,如果返回了NULL,说明没有树里没有进程,CFS安排idle进程。
向树添加进程
当一个进程被唤醒或者通过fork()
新被创建,调用enqueue_entity()
来向红黑树添加进程。这个函数首先更新vruntime等数据,然后调用__enqueue_entity()
来真正把进程结点插入到树中:
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) {
struct rb_node **link = &cfs_rq->tasks_timeline.rb_node;
struct rb_node *parent = NULL;
struct sched_entity *entry;
s64 key = entity_key(cfs_rq, se);
int leftmost = 1;
// Find the right place in the rbtree:
while (*link) {
parent = *link;
entry = rb_entry(parent, struct sched_entity, run_node);
// We dont care about collisions. Nodes with the same key stay together.
if (key < entity_key(cfs_rq, entry)) {
link = &parent->rb_left;
} else {
link = &parent->rb_right;
leftmost = 0;
}
}
// Maintain a cache of leftmost tree entries (it is frequently used):
if (leftmost)
cfs_rq->rb_leftmost = &se->run_node;
rb_link_node(&se->run_node, parent, link);
rb_insert_color(&se->run_node, &cfs_rq->tasks_timeline);
}
while()循环遍历树找到该进程结点所在的位置,同时维护leftmost变量,若该结点一直往左,则需要更新rb_leftmost,若有一步是往右就不需要更新。跳出循环后,对父进程调用 rb_link_node()
,将新插入的进程结点设置为子结点。rb_insert_color()
更新结点颜色来维护红黑树的性质。
从树中删除进程
当一个进程阻塞或者终止,会调用dequeue_entity()
来删除进程,核心功能是由__dequeue_entity()
实现的:
static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) {
if (cfs_rq->rb_leftmost == &se->run_node) {
struct rb_node *next_node;
next_node = rb_next(&se->run_node);
cfs_rq->rb_leftmost = next_node;
}
rb_erase(&se->run_node, &cfs_rq->tasks_timeline);
}
红黑树中rb_erase()
函数就可以完成所有删除任务,只需要更新rb_leftmost即可。若该进程是最左进程,rb_next()
可以直接返回中序遍历中的下一个结点,即新的rb_leftmost。
The Scheduler Entry Point
进程调度的主要入口是 schedule()
函数,它对所有调度器类是通用的。其中pick_next_task()
先遍历调度器类,找到优先级最高的类,再从这个类里找到优先级最高的进程。
函数初始有一个优化,如果可运行的进程数和CFS中可运行的进程数相等,即所有进程都在一个类里,很快就能找到下一个进程。
pick_next_task()
会调用pick_next_entity()
,pick_next_entity()
又会调用__pick_next_entity()
,在之前有介绍过。
Sleeping and Waking Up
进程会因为等待I/O输入、硬件输入或者信号量进入睡眠。此时:进程标记自己为sleeping,把自己放入等待队列,将自己从可运行进程的红黑树中移除,调用schedule()
去选择一个新进程运行。唤醒就是相反的过程:将进程设置为runnable,从等待队列中移除,加回到红黑树。Sleeping进程可能是TASK_INTERRUPTABLE或者TASK_UNINTERRUPTABLE,这两种情况下进程都是在等待队列上,是不可运行的。
Wait Queues
等待队列是由wake_queue_head_t
实现,可以通过你DECLARE_WAITQUEUE()
静态创建或者init_waitqueue_head()
动态创建。sleep函数的实现要防止进程在唤醒条件达成之后sleep,这样会陷入无限sleep的状态。推荐这样实现:
/* ‘q’ is the wait queue we wish to sleep on */
DEFINE_WAIT(wait);
add_wait_queue(q, &wait);
while (!condition) { /* condition is the event that we are waiting for */
prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);
if (signal_pending(current))
/* handle signal */
schedule();
}
finish_wait(&q, &wait);
总共有这些步骤:
- 通过宏
DEFINE_WAIT()
创建等待队列。 - 通过
add_wait_queue()
将进程添加至等待队列,此时若唤醒条件满足,队列会唤醒进程。 - 调用
prepare_to_wait()
函数将进程状态改成TASK_INTERRUPTIBLE或者TASK_UNINTERRUPTIBLE。如果有需要,该函数在之后的循环里也会将进程重新加回到等待队列。 - 如果进程状态是TASK_INTERRUPTIBLE,有信号唤醒进程,这被称作a spurious wake up,此时处理信号。
- 这时进程是唤醒状态,检查唤醒条件是否成立,如果成立,停止循环,否则调用
schedule()
继续循环。 - 当跳出循环后,进程通过
finish_wait()
函数将自己状态设置为TASK_RUNNING,并从等待队列移除。
这样即使唤醒条件在进程sleep前发生,进程也不会错误的进入睡眠。很多情况,它可能需要在调用schedule()
之前释放锁,然后在其他事件发生或对其他事件做出反应之前重新获取它们。此时检查唤醒条件是否成立就会在while循环体里,然后通过break跳出循环。
waking up
使唤醒事件发生的代码会自己调用wake_up()
函数,唤醒在等待队列上的所有进程:
- 调用
try_to_wake_up()
,将进程状态设置为TASK_RUNNING。 - 调用
enqueue_task()
将进程添加到红黑树。 - 如果唤醒的进程优先级比当前进程优先级高,设置need_resched。
因为进程会因为信号被唤醒,并非真正的唤醒事件发生,所以sleeping需要在循环里处理来保证唤醒事件真正发生了。
抢占和上下文切换
上下文切换发生在从一个可运行进程切换至另一个可运行进程,由context_switch()
实现,而该函数是在新进程被选择运行时schedule()
调用的。做两件事:
- 调用
switch_mm()
,切换虚拟内存。 - 调用
switch_to()
,切换处理器状态,包括保存栈信息、处理器寄存器及任何需要对每个进程进行管理和存储的特定架构的状态。
内核通过标志need_resche来标识是否需要重新调度。当一个进程应该被抢占时scheduler_tick()
会设置该标志,或者当唤醒的进程比当前进程有更高的优先级时try_to_wake_up()
也会设置该标志。当内核检查(如从中断返回到用户态)发现有设置这个标志时就会调用schedule()
切换至新进程。该标志是每个进程都有一个,存在process descriptor中。
用户抢占
- 当从system call返回至用户态
- 当从中断返回至用户态
这两种情况返回用户态会检查need_resche是否被设置,若被设置,调度器选择新进程运行。
内核抢占
- 中断处理器退出,返回至内核态之前。
- 内核代码是可抢占的,包括need_resche被设置且preempt_count为零。
- 当内核的进程显式调用
schedule()
,比如进程被阻塞。
实时调度策略
非实时的调度策略是SCHED_NORMAL,实时调度策略有SCHED_FIFO和SCHED_RR,是由实时的特殊调度器调度的。
- SCHED_FIFO就是first-in、first-out,没有时间片。一个SCHED_FIFO进程调度是在任何SCHED_NORMAL进程之上的。当一个SCHED_FIFO进程可运行,所有优先级比它低的进程都变成不可运行的。它可以一直运行直到更高优先级的SCHED_FIFO或SCHED_RR进程抢占使之被阻塞,或它主动退出处理器占用。多个同等优先级的SCHED_FIFO进程是轮循执行直到某一进程自己选择退出。
- SCHED_RR和SCHED_FIFO完全一样,除了它有时间切片的概念,即每个进程只能运行到耗尽时间切片为止。时间片是只允许对相同优先级的进程进行重新调度的,高优先级的进程可以立刻抢占低优先级的进程。
实时调度都是使用静态优先级,保证给定优先级的进程可以一直抢占低优先级的进程。
调度相关的系统调用
Linux提供了一系列系统调用可以管理调度参数:
调度策略和优先级相关
nice()
:普通进程调用设置nice value。只有root可以设置为负数。该函数调用set_user_nice()
函数,设置task_struct中的static_prio和prio值。sched_setscheduler()
和sched_getscheduler()
:设置或获取某一进程的调度策略或实时优先级。仅仅读或写task_struct中的policy和rt_priority值。sched_setparam()
和sched_getparam()
:设置或获取某一进程的实时优先级。仅仅对sched_param数据结构中的rt_priority进行解码。sched_get_priority_max()
和sched_get_priority_min()
获取某调度策略下的最大最小优先级。在实时策略中,最大值为MAX_USER_RT_PRIO-1,最小值为1。
处理器关联
硬关联(hard affinity)是指用位掩码(bitmask)记录该进程可以在哪个处理器上运行。位掩码是存在task_struct的cpus_allowed中,默认所有位都设置了,即可以在任一处理器上运行。用户可以通过sched_setaffinity()
设置不同组合的位掩码。sched_getaffinity()
就是返回当前cpus_allowed值。
内核以简单的方式强制执行硬关联:
- 一个进程刚创建时,继承父进程的位掩码。
- 当一个处理器的affinity变动,内核用migration threads将进程推到合法的处理器上。
- 负载均衡器仅将进程拉到允许的处理器上。
放弃处理器
当进程想要放弃处理器,让处理器处理其他等待进程时,可以调用sched_yield()
,该函数将该进程从活跃数组里删除,插入到过期数组里(实时进程没有过期数组不插入),以保证短时间内不会再运行该进程。内核代码可以先调用yield()
保证进程是在运行,再调用sched_yield()
。用户态应用直接调用系统调用sched_yield()
即可。