《Linux 内核设计与实现》读书笔记-进程调度
process scheduler(简称 scheduler) 决定运行哪个 Process,什么时候运行以运行多长时间。其主要 idea 就是最大化地利用 processor time
多任务(Multitasking) OS 有两种:
- cooperative multitasking,除非进程主动放弃,不然会一直运行。调度器无法决定一个进程能够运行多长时间,因此进程可以独占处理器
- preemptive multitasking,一个运行中的进程可以被抢占。Linux 采用这种方式
Scheduler 将 CPU 时间划分为时间片(timeslice),scheduler 的工作就是管理这些时间片。在现代 OS 中,时间片通常是根据进程的行为和系统配置动态计算的。
O(1) scheduler 可以轻松支持数十甚至上百个处理器,但是对调度 latency-sensitive applications 有些问题,这些 applications 被称为 interactive processes(包括所有与用户交互的应用)
Rotaing Staircase Deadline scheduler 引入 fair scheduling
的概念,–> Completely Fair Scheduler(CFS)
Schedule class
有两种方法可以激活调度,一种是直接的,比如进程打算睡眠或因为其他原因放弃 CPU;另一种是周期性检查。这两种组件称为通用调度器(generic scheduler)或核心调度器(core scheduler)
schedule class 提供了通用调度器和各个调度方法之间的关联,用于判断接下来运行哪个进程,kernel 支持不同的调度策略(完全公平调度、实时调度等),schedule class 能够以模块化的方式实现这些策略。每个进程都只属于一个调度类,进程调度时,由所属的调度类来调度。
所有的 schedule class 通过链表的形式连接起来,这个结构在 /kernel/sched/sched.h
中。
1 | struct sched_class { |
每个 CPU 都有自身的就绪队列,就绪队列通过 struct rq
来表示,每个就绪队列中有两个队列,分别代表两个 schedule class (完全公平调度, 实时调度) 管理的队列。各个活动进程都只会出现在一个 CPU 的一个就绪队列中
1 | struct rq { |
Priority
进程大致可以分为两类
- I/O-Bound,更多的时间花在 I/O 上,即大部分时间都在等待 I/O
- Processor-Bound,更加关注在执行 code 上
目标:快速响应(低延迟)、最大化地利用系统(高吞吐)
Unix 更加关注在 I/O 密集型进程
基于优先级的调度:给进程排优先级,优先级高的先执行(有些系统可能会有更多的时间片),优先级低的后执行。相同优先级可以采用 RR。
Linux 实现了两种 priority ranges:
nice value
, 从 -20 到 19,默认为 0。nice value 越大,优先级越小,能够使用的 CPU 时间的比例越少。可以使用ps -el
查看 nice value(NI 列)。nice value 设置的优先级也是 static priority,可以通过nice
和sched_setscheduler
进行修改real-time priority
,这个值是可配置的,默认范围为 [0,99]。real-time priority 越大,优先级越高。实时进程的优先级比普通进程的优先级要高。可以使用ps -eo rtprio
查看
在 Linux 中,使用一个简单的数值范围 [0, 139] 来表示 nice value 和 real-time priority,其中 0~99 分配给 real-time priority,100~139 映射到 nice value。Linux 中,这个数值越大,优先级越小。(对于实时进程的优先级,会采用 99 - rt_priority 的形式计算,因此 rt_priority 越大,这个数值越小,优先级就越大。而对于普通进程,会使用 (120 + nice) 进行计算)
在 task_struct
中有三个字段表示优先级
static_prio
,静态优先级,进程启动时分配的优先级,可以用nice
,sched_setscheduler
系统调用修改normal_prio
,基于静态优先级和调度策略计算出的优先级。实时进程的优先级为MAX_RT_PRIO - 1 - rt_prio
,普通进程的优先级根据 nice 值计算(nice + DEFAULT_PRIO
)。具体的可以看这里prio
,动态优先级。如果一个普通进程临时提高优先级,那么就会使用这个字段的值作为优先级,否则使用normal_prio
作为优先级。具体可以看这里rt_priority
,表示实时进程的优先级,实时进程的优先级总是高于普通进程。如果一个普通进程临时提高优先级,会使用进程
进程类型/优先级 | static_prio | normal_prio | prio |
---|---|---|---|
非实时进程 | static_prio | static_prio | static_prio |
优先级提高的非实时进程 | static_prio | static_prio | prio 不变 |
实时进程 | static_prio | MAX_RT_PRIO - 1 - rt_prio | prio 不变 |
时间片的选取既不能太长,也不能太短。太长会导致交互性变差,太短又会在上下文切换上消耗过多。
同时,将 nice value 映射到固定的时间片也不是一个好方法。比如
- nice value 分别为 0 和 20 的时间片分别为 100 和 5ms。但是如果只有两个 nice value 为 0 的进程会导致每隔 100ms 切换一次,这个时间片太长了;如果只有两个 nice value 为 20 的进程,就会每隔 5ms 切换一次,这个时间片太短了。
- 而且 nice value 为 0 和 1 时间片的差距(比如 100, 95)与 nice value 为 18 和 19 的差距(比如 10,5)会很不一样,这样很不合理。
CFS 调度器解决这些问题通过:
- nice value geometric instead of additive
- 将 nice value 映射为 比例而非固定的值,比如 nice 每减少 1,获得的时间片增加 10%
Context Switch
switch_mm
切换task_struct->mm
描述的内存管理上下文,包括 page table,TLB等,细节取决于处理器switch_to
切换处理器寄存器内容和内核栈(包括用户状态下的栈)
Fair Scheduling
理想中完美的调度模型是 n 个进程,每个进程都使用 的 CPU 时间,并在无限小的时间内调度它们,让他们都运行相同的时间。但是调度会进行上下文切换,所以这种模型是不可能实现的。
CFS 就是基于这种模型实现的。CFS 会尽量让每个进程运行相同多的时间,并使用 RR 等调度算法将运行时间最少得进程选座下一个运行的进程。
CFS 会记录从上次执行开始到现在的时间,并会根据当前 runnable 的进程数量对其进行调整,得到一个加权后的 vruntime(virtual runtime)
作为其运行的总时间,并利用 vruntime
来估算这个进程应该获取多少时间片。
原文:
The vruntime variable stores the virtual runtime of a process, which is the actual runtime(the amount of time spent running) normalized(or weighted) by the number of runnable processes.
update_curr() calculates the execution time of the current process and stores that value in delta_exec. It then pass that runtime to __update_curr(), which weights the time by the number of runnable processes. The current process’s vruntime is then increment by the weighted value
不是很理解这里说的 “weights the time by the number of runnable processes”。应该是根据进程的 nice value 来分配权重,nice value 越小,权重越大。计算 vruntime
时权重越大,其 vruntime
加得越慢。
vruntime 增加的速度依赖于等待调度器挑选的进程的数目,比如有 N 个进程,那么 vruntime 将以实际时钟 1/4 的速度运行。
1 | static void update_curr(struct cfs_rq *cfs_rq) { |
update_curr()
有两种调用时机:
- 周期性地被系统定时器调用
- 当进程变为 runnable 或 blocks 或 unrunnable 时调用
选择下一个运行的进程
CFS 的核心思想就是选择 vruntime
最小的进程调度。
CFS 使用红黑树来管理 runnable 进程,当需要调度时,从红黑树中取出 vruntime
最小的进程,并会将其缓存起来。当进程变为 runnable 或被创建时,也会想红黑树中新增一个节点。当进程变为 block 或 终止时,会从红黑树中移除。
实际红黑树中 key 的存储是 vruntime - min_vruntime。min_vruntime 跟踪 cfs 就绪队列上所有进程的最小 vruntime(可能比节点的 vruntime 还大)只有在树的节点的 vruntime 超过时 min_vruntime 时才会更新(确保 min_vruntime 单调递增)。vruntime 会随着时间增加,这可以减缓不公平状态
- 进程运行时,vruntiem 增加得越慢,节点向右移动的速度也越慢,即高优先级的进程更有可能被调度
- 当进程进入 sleep 状态时,vruntime 会保持不变,但是队列中的 min_runtime 可能会增加,那么当进程唤醒后,节点会往左移,即更有可能被调度。实际上当进程被环境后会补偿一定的 vruntime
- 新创建进程时,子进程会继承父进程的 vruntime(之前的版本可能会在父进程的 vruntime 的基础之上增加或减少一定的 vruntime)
Real-time Schedule
实时进程的优先级总是比普通进程高。如果系统各种有一个实时进程,并且可以运行,那么调度器总是会选中它运行。
有两种实时调度类:
- 循环进程(SCHED_RR) 有时间片,采用 RR 来执行
- 先进先出进程(SCHED_FIFI),没有时间片,可以运行任意长时间
实时调度类的就绪队列采用链表实现,具有相同优先级的所有实时进程都保存在一个链表中。在 SCHED_RR
中,如果一个进程时间片用完,会重新放入到链表的末尾
1 | struct rt_prio_array { |
Enhance
多核处理器上必须考虑几个问题
- CPU 负载必须尽可能公平地在所有处理器上共享
- 周期调度器函数
scheduler_tick
完成任务后会调用trigger_load_balance
引发SCHEDULE_SOFTIRQ
软中断,该中断会在适当时机执行run_rebalance_domains
,最终对当前 CPU 调用rebalance_domains
实现负载均衡
- 周期调度器函数
- 进程与系统中某些处理器的亲和性(affinity) 必须可以设置
- 内核必须能够将进程从一个 CPU 迁移到另一个 CPU
- 内核为每个就绪队列提供了一个迁移线程,可以接受迁移请求,这些请求保存在
migration_queue
链表中。通常发源于调度器自身。 - 可以优先在物理上临近或共享高速缓存的 CPU之间迁移
- 内核为每个就绪队列提供了一个迁移线程,可以接受迁移请求,这些请求保存在