注:使用内核源码版本4.12
1 调度器的实现
可以用两种方法激活调度。一种是直接的,比如进程打算睡眠或出于其他原因放弃CPU;另一种是通过周期性机制,以固定的频率运行,不时检测是否有必要进行进程切换。
实现这两种调度方式的组件称为通用调度器( generic scheduler)或核心调度器( core scheduler)。除此之外还有另外两个组件:
- 调度类实现判断接下来运行哪个进程。内核支持不同的调度策略(完全公平调度、实时调度、在无事可做时调度空闲进程等等),调度类使得能够以模块化方法实现这些策略。每个进程都刚好属于某一调度类,核心调度器自身完全不涉及进程管理,其工作都委托给调度器类。在调度器被调用时,它会查询这些调度器类,得知接下来运行哪个进程。
- 选中将要运行的进程之后,必须执行任务的上下文切换。
1.1 核心调度器
调度器的实现基于两个函数:周期性调度器函数和主调度器函数。这些函数根据现有进程的优先级分配CPU时间。这也是为什么整个方法称之为优先调度的原因。
还有一个概念需要先提前了解一下:每个处理器都有一个运行队列(又叫就绪队列),结构体是struct rq
,各个CPU的本地可运行进程在本地队列上排队等待运行。后文还会做更详细的介绍。
周期性调度器(scheduler_tick)
周期性调度器在scheduler_tick中实现。如果系统正在活动中,内核会按照频率HZ自动调用该函数。
scheduler_tick()
主要完成两个任务:
- 1、管理与调度相关的各种统计量,主要操作是对各种计数器加1。对我们而言这不是特别重要的部分。
- 2、激活负责当前进程所属调度类的周期性调度方法。都有哪些调度类我们晚点再谈。
先看一下源码(位于kernel/sched/core.c):
/* This function gets called by the timer code, with HZ frequency. We call it with interrupts disabled. */
void scheduler_tick(void)
{
/* 1 - 获取当前CPU上的就绪队列rq和当前运行进程curr */
int cpu = smp_processor_id(); // 在SMP的情况下,获取当前CPU的ID;如果不是SMP则返回0; 此处先不管SMP
struct rq *rq = cpu_rq(cpu); // 获取当前CPU上的就绪队列 rq
struct task_struct *curr = rq->curr; // 获取就绪队列上当前正在执行的进程
struct rq_flags rf;
sched_clock_tick();
rq_lock(rq, &rf);
/* 2 - 更新rq当前时间戳,本质就是增加 rq->clock 时间戳;*/
update_rq_clock(rq);
/* 3 - 执行当前进程所在调度类的 task_tick 函数,激活周期性调度 */
curr->sched_class->task_tick(rq, curr, 0);
/* 4 - 将当前负荷加入到数组的第一个位置 rq->cpu_load[0] */
cpu_load_update_active(rq);
/* 5 - 更新CPU的活动计数,即计算当前CPU就绪队列的 calc_load_update */
calc_global_load_tick(rq);
rq_unlock(rq, &rf);
perf_event_task_tick(); // 与perf计数事件有关
#ifdef CONFIG_SMP
rq->idle_balance = idle_cpu(cpu); // 判断CPU是否为空闲状态
trigger_load_balance(rq); // 如果进程周期性负载平衡则触发 SCHED_SOFTIRQ 中断
#endif
rq_last_tick_reset(rq);
}
第2步中更新时间戳的过程我们不是很关心。
由于调度器的模块化结构,主体代码实际上比较简单,因为主要的工作可以委托给特定调度器类的方法来完成:
curr->sched_class->task_tick(rq, curr, 0);
第3步中task_tick
函数显然是多态形式的调度器类方法,具体实现方式取决于底层的调度器类 sched_class,具体在后面调度类相关内容中再来看。如果当前进程应该被重新调度,那么调度器类方法会在task_struct中设置TIF_NEED_RESCHED
标志,以表示该请求,而内核会在接下来的适当时机调用主调度器函数完成该请求。
第4步的cpu_load_update_active(rq)
在低层是执行cpu_load_update()
,此函数更新负荷数据到数组rq->cpu_load[]
中,本质上相当于将数组中先前存储的负荷值向后移动一个位置,将当前就绪队列的负荷记入数组的第一个位置。从这个函数的注释来看,内部采用了一些计算负荷平均值的方法还是比较复杂的,其目的是确保负荷数组的内容不会呈现出太多的不连续跳变。
主调度器(schedule)
周期性调度器只是定时更新调度相关的统计信息。主调度器则负责将CPU的使用权从一个进程切换到另一个进程。
在内核中如果要将CPU分配给另一个进程,都会直接调用主调度器函数( schedule)。在从系统调用返回之后,内核也会检查当前进程是否设置了重调度标志TIF_NEED_RESCHED,例如,前述的scheduler_tick就会设置该标志。如果是这样,则内核会调用 schedule()。
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
sched_submit_work(tsk);
do {
preempt_disable();
__schedule(false);
sched_preempt_enable_no_resched();
} while (need_resched());
}
EXPORT_SYMBOL(schedule);
need_resched()
位于 include/linux/sched.h
static __always_inline bool need_resched(void)
{
return unlikely(tif_need_resched());
}
#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)
显然只要线程的TIF_NEED_RESCHED
标志处于置位状态,就需要继续执行__schedule(false)
。目前只需要知道这些,关于__schedule
的代码在后面再来做详细研究。
思考:为什么需要周期性调度器?
有些“地痞流氓”进程不会主动让出处理器,内核只能依靠周期性的时钟中断夺回处理器的控制权,时钟中断是调度器的脉博。时钟中断处理程序检查当前进程的执行时间有没有超过限额,如果超过限额,设置重新调度的标志TIF_NEED_RESCHED。当时钟中断处理程序准备讲CPU还给被打断的进程时,如果被打断的进程在用户模式下运行,就检查有没有设置TIF_NEED_RESCHED ,如果设置了,就调用主调度器函数schedule()进行重新调度。
1.2 调度类
为方便添加新的调度策略(将核心调度器的实现与具体调度算法分开实现),Linux内核抽象了一个调度类sched_class
,目前为止实现5种调度类:
5种调度类的优先级从高到低:停机 > 限期 > 实时 > 公平 > 空闲。核心调度器也需按这个顺序对调度类进行调用,实际上后面我们在调度类定义struct sched_class
中可以看到其成员变量next
以链表的形式将各个调度类按优先级顺序串起来。
在详细介绍各个调度类前,有必要先了解一下运行队列(run-queue,有时又叫就绪队列)和调度实体。
运行队列
每个处理器会维护一个运行队列,CPU本地队列中的进程如果处于就绪状态就加入到队列中排队等待被调度。其定义位于kernel/sched/core.c,数据结构如下:
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);
/* This is the main, per-CPU runqueue data structure.
*
* Locking rule: those places that want to lock multiple runqueues
* (such as the load balancing or the thread migration code), lock
* acquire operations must be ordered by ascending &runqueue.
*/
struct rq {
/* runqueue lock: */
raw_spinlock_t lock;
/* nr_running and cpu_load should be in the same cacheline
because remote CPUs use both these fields when doing load calculation. */
unsigned int nr_running;
...
#define CPU_LOAD_IDX_MAX 5
unsigned long cpu_load[CPU_LOAD_IDX_MAX];
...
struct cfs_rq cfs; // 公平调度的队列
struct rt_rq rt; // 实时调度的队列
struct dl_rq dl; // 限期调度的队列
...
struct task_struct *curr;
struct task_struct *idle; // 空闲进程的 task_struct
struct task_struct *stop; // 停机进程的 task_struct
unsigned long next_balance;
struct mm_struct *prev_mm;
...
从添加了注释的行可以看到,每个CPU的运行队列中又为公平调度类、实时调度类以及限期调度类嵌入了一个对应的运行队列,而对于空闲调度类和停机调度类只指定了对应进程的描述符task_struct
,这是因为停机调度类和空闲调度类在每个CPU上只有一个内核线程,因此不需要定义运行队列,只需要定义task_struct
指针指向迁移线程和空闲线程即可。
调度实体
进程以调度实体的形式作为调度器的调度对象,普通进程的调度实体其数据结构如下:
struct sched_entity {
/* For load-balancing: */
struct load_weight load;
struct rb_node run_node; // 当前调度实体在红黑树种的节点(公平调度采用红黑树)
struct list_head group_node;
unsigned int on_rq; // 当前调度实体是否在就绪队列上
u64 exec_start; // 当前实体上次被调度执行的时间
u64 sum_exec_runtime; // 当前实体总执行时间
u64 vruntime; // 当前实体的虚拟时间,调度器就是通过调度实体的虚拟时间进行调度,在选择下一个待执行实体时总是选择虚拟时间最小的。
u64 prev_sum_exec_runtime; // 截止到上次统计,进程执行的时间,通常,通过 sum_exec_runtime - prev_sum_exec_runtime 来统计进程本次在 CPU 上执行了多长时间
u64 nr_migrations; // 实体执行迁移的次数,在多核系统中,CPU 之间会经常性地执行负载均衡操作,因此调度实体很可能因为负载均衡而迁移到其它 CPU 的就绪队列上。
struct sched_statistics statistics; // 进程的属性统计
...
};
每个进程的task_struct
中都至少定义了三个调度实体:
struct task_struct {
...
const struct sched_class *sched_class; // 调度类
struct sched_entity se; // 普通进程,公平调度实体
struct sched_rt_entity rt; // 实时调度实体
#ifdef CONFIG_CGROUP_SCHED
struct task_group *sched_task_group;
#endif
struct sched_dl_entity dl; // 限期调度实体
...
};
接下来对各个调度类做介绍。其中会详细说明实时调度类和公平调度类。
1) stop_sched_class 停机调度类
所谓停机就是指使CPU停下。其优先级最高,没有时间片,可用于进程在处理器之间迁移,迁移进程的优先级必须比限期进程还要高,以便于能够抢占所有进程,这样才能确保及时响应调度器发生的迁移请求。
停机调度类的类定义如下:
/* Simple, special scheduling class for the per-CPU stop tasks: */
const struct sched_class stop_sched_class = {
.next = &dl_sched_class, // 停机调度类的下一个是限期调度类
.enqueue_task = enqueue_task_stop,
.dequeue_task = dequeue_task_stop,
.yield_task = yield_task_stop,
.check_preempt_curr = check_preempt_curr_stop,
.pick_next_task = pick_next_task_stop,
.put_prev_task = put_prev_task_stop,
....