本文首发于 http://oliveryang.net,转载时请包含原文或者作者网站链接。
本文主要围绕 Linux 内核调度器 Preemption 的相关实现进行讨论。其中涉及的一般操作系统和 x86 处理器和硬件概念,可能也适用于其它操作系统。
1. Scheduler Overview
Linux 调度器的实现实际上主要做了两部分事情,
任务上下文切换
在 Preemption Overview 里,我们对任务上下文切换做了简单介绍。可以看到,任务上下文切换有两个层次的实现:公共层和处理器架构相关层。任务运行状态的切换的实现最终与处理器架构密切关联。因此 Linux 做了很好的抽象。在不同的处理器架构上,处理器架构相关的代码和公共层的代码相互配合共同实现了任务上下文切换的功能。这也使得任务上下文切换代码可以很容易的移植到不同的处理器架构上。
任务调度策略
同样的,为了满足不同类型应用场景的调度需求,Linux 调度器也做了模块化处理。调度策略的代码也可被定义两层 Scheduler Core (调度核心) 和 Scheduling Class (调度类)。调度核心的代码实现了调度器任务调度的基本操作,所有具体的调度策略都被封装在具体调度类的实现中。这样,Linux 内核的调度策略就支持了模块化的扩展能力。Linux v3.19 支持以下调度类和调度策略,
- Real Time (实时)调度类 - 支持 SCHED_FIFO 和 SCHED_RR 调度策略。
- CFS (完全公平)调度类 - 支持 SCHED_OTHER(SCHED_NORMAL),SCHED_BATCH 和 SCHED_IDLE 调度策略。(注:SCHED_IDLE 是一种调度策略,与 CPU IDLE 进程无关)。
- Deadline (最后期限)调度类 - 支持 SCHED_DEADLINE 调度策略。
Linux 调度策略设置的系统调用 SCHED_SETATTR(2) 的手册有对内核支持的各种调度策略的详细说明。内核的调度类和
sched_setattr
支持的调度策略命名上不一致但是存在对应关系,而且调度策略的命名更一般化。这样做的一个好处是,同一种调度策略未来可能有不同的内核调度算法来实现。新的调度算法必然引入新的调度类。内核引入新调度类的时候,使用这个系统调用的应用不需要去为之修改。调度策略本身也是 POSIX 结构规范的一部分。上述调度策略中,SCHED_DEADLINE 是 Linux 独有的,POSIX 规范中并无此调度策略。SCHED(7) 对 Linux 调度 API 和历史发展提供了概览,值得参考。
1.1 Scheduler Core
调度器核心代码位于 kernel/sched/core.c 文件。主要包含了以下实现,
- 调度器的初始化,调度域初始化。
- 核心调度函数
__schedule
及上下文切换的通用层代码。 - 时钟周期处理的通用层代码,包含 Tick Preemption 的代码。
- 唤醒函数,Per-CPU Run Queue 操作的代码,包含 Wakeup Preemption 通用层的代码。
- 基于高精度定时器中断实现的高精度调度,处理器间调度中断。
- 处理器 IDLE 线程,调度负载均衡,迁移任务的代码。
- 与调度器有关的系统调用的实现代码。
调度器核心代码的主要作用就是调度器的模块化实现,降低了跨处理器平台移植和实现新调度算法模块的重复代码和模块间的耦合度,提高了内核可移植性和可扩展性。
1.2 Scheduling Class
在 Linux 内核引入一种新调度算法,基本上就是实现一个新的 Scheduling Class (调度类)。调度类需要实现的所有借口定义在 struct sched_class
里。下面对其中最重要的一些调度类接口做简单的介绍,
enqueue_task
将待运行的任务插入到 Per-CPU Run Queue。典型的场景就是内核里的唤醒函数,将被唤醒的任务插入 Run Queue 然后设置任务运行态为
TASK_RUNNING
。对 CFS 调度器来说,则是将任务插入红黑树,给
nr_running
增加计数。dequeue_task
将非运行态任务移除出 Per-CPU Run Queue。典型的场景就是任务调度引起阻塞的内核函数,把任务运行态设置成
TASK_INTERRUPTIBLE
或TASK_UNINTERRUPTIBLE
,然后调用schedule
函数,最终触发本操作。对 CFS 调度器来说,则是将不在处于运行态的任务从红黑树中移除,给
nr_running
减少计数。yield_task
处于运行态的任务申请主动让出 CPU。典型的场景就是处于运行态的应用调用
sched_yield(2)
系统调用,直接让出 CPU。此时系统调用sched_yield
系统调用先调用yield_task
申请让出 CPU,然后调用schedule
去做上下文切换。对 CFS 调度器来说,如果
nr_running
是 1,则直接返回,最终schedule
函数也不产生上下文切换。否则,任务被标记为 skip 状态。调度器在红黑树上选择待运行任务时肯定会跳过该任务。之后,因为schedule
函数被调用,pick_next_task
最终会被调用。其代码会从红黑树中最左侧选择一个任务,然后把要放弃运行的任务放回红黑树,然后调用上下文切换函数做任务上下文切换。check_preempt_curr
用于在待运行任务插入 Run Queue 后,检查是否应该 Preempt 正在 CPU 运行的当前任务。Wakeup Preemption 的实现逻辑主要在这里。
对 CFS 调度器而言,主要是在是否能满足调度时延和是否能保证足够任务运行时间之间来取舍。CFS 调度器也提供了预定义的 Threshold 允许做 Wakeup Preemption 的调优。本文有专门章节对 Wakeup Preemption 做详细分析。
pick_next_task
选择下一个最适合调度的任务,将其从 Run Queue 移除。并且如果前一个任务还保持在运行态,即没有从 Run Queue 移除,则将当前的任务重新放回到 Run Queue。内核
schedule
函数利用它来完成调度时任务的选择。对 CFS 调度器而言,大多数情况下,下一个调度任务是从红黑树的最左侧节点选择并移除。如果前一个任务是其它调度类,则调用该调度类的
put_prev_task
方法将前一个任务做正确的安置处理。但如果前一个任务如果也属于 CFS 调度类的话,为了效率,跳过调度类标准方法put_prev_task
,但核心逻辑仍旧是put_prev_task_fair
的主要部分。关于put_prev_task
的具体功能,请参考随后的说明。put_prev_task
将前一个正在 CPU 上运行的任务做拿下 CPU 的处理。如果任务还在运行态则将任务放回 Run Queue,否则,根据调度类要求做简单处理。此函数通常是
pick_next_task
的密切关联操作,是schedule
实现的关键部分。如果前一个任务属于 CFS 调度类,则使用 CFS 调度类的具体实现
put_prev_task_fair
。此时,如果任务还是TASK_RUNNING
状态,则被重新插入到红黑树的最右侧。如果这个任务不是TASK_RUNNING
状态,则已经从红黑树移除过了,只需要修改 CFS 当前任务指针cfs_rq->curr
即可。select_task_rq
为给定的任务选择一个 Run Queue,返回 Run Queue 所属的 CPU 号。典型的使用场景是唤醒,fork/exec 进程时,给进程选择一个 Run Queue,这也给调度器一个 CPU 负载均衡的机会。
对 CFS 调度器而言,主要是根据传入的参数要求找到符合亲和性要求的最空闲的 CPU 所属的 Run Queue。
set_curr_task
当任务改变自己的调度类或者任务组时,该函数被调用。用户进程可以使用
sched_setscheduler
系统调用,通过设置自己新的调度策略来修改自己的调度类。对 CFS 调度器而言,当任务把自己调度类从其它类型修改成 CFS 调度类,此时需要把该任务设置成正当前 CPU 正在运行的任务。例如把任务从红黑树上移除,设置 CFS 当前任务指针
cfs_rq->curr
和调度统计数据等。task_tick
这个函数通常在系统周期性 (Per-tick) 的时钟中断上下文调用,调度类可以把 Per-tick 处理的事务交给该方法执行。例如,调度器的统计数据更新,Tick Preemption 的实现逻辑主要在这里。Tick Preemption 主要判断是否当前运行任务需要 Preemption 来被强制剥夺运行。
对 CFS 调度器而言,Tick Preemption 主要是在是否能满足调度时延和是否能保证足够任务运行时间之间来取舍。CFS 调度器也提供了预定义的 Threshold 允许做 Tick Preemption 的调优。需要进一步了解 Tick Preemption,请参考 2.1 章节。
Linux 内核的 CFS 调度算法就是通过实现该调度类结构来实现其主要逻辑的,CFS 的代码主要集中在 kernel/sched/fair.c 源文件。下面的 sched_class
结构初始化代码包含了本节介绍的所有方法在 CFS 调度器实现中的入口函数名称,
const struct sched_class fair_sched_class = {
[...snipped...]
.enqueue_task = enqueue_task_fair,
.dequeue_task = dequeue_task_fair,
.yield_task = yield_task_fair,
[...snipped...]
.check_preempt_curr = check_preempt_wakeup,
[...snipped...]
.pick_next_task = pick_next_task_fair,
.put_prev_task = put_prev_task_fair,
[...snipped...]
.select_task_rq = select_task_rq_fair,
[...snipped...]
.set_curr_task = set_curr_task_fair,
[...snipped...]
.task_tick = task_tick_fair,
[...snipped...]
};
1.3 preempt_count
Linux 内核为支持 Kernel Preemption 而引入了 preempt_count
计数器。如果 preempt_count
为 0,就允许 Kernel Preemption,否则就不允许。内核函数 preempt_disable
和 preempt_enable
用来内核代码的临界区动态关闭和打开 Kernel Preemption。其主要原理就是要通过对这个计数器的加和减来实现关闭和打开。
一般而言,打开 Kernel Preemption 特性的内核,在尽可能的情况下,允许在内核态通过 Tick Preemption 和 Wakeup Preemption 去触发和执行 Kernel Preemption。但在以下情形,Kernel Preemption 会有关闭和打开操作,
- 内核显式调用
preempt_disable
关闭抢占期间, - 进入中断上下文时,
preempt_count
计数器被加操作置为非零。退出中断时打开 Kernel Preemption。 - 获取各种内核锁以后,
preempt_disable
被间接调用。退出内核锁会有preempt_enable
操作。
关于preempt_disable
和 preempt_enable
的用法,请参考 Proper Locking Under a Preemptible Kernel。
早期 Linux 内核,preempt_count
是每个任务所属的 struct thread_info
里的一个成员,是 Per-thread 的。
而在 Linux 新内核,为了优化 Kernel Preemption 带来的频繁检查 preempt_count
的开销,Linus 和调度器的维护者决定对其做更多的优化。因此,Per-CPU preempt_count
的优化被集成到 3.13 版内核。所以,新内核的的 preempt_count
定义如