2.5 调度器的实现
2.5.1 概观
进程调度:CPU同一时刻只能执行一个任务。各个进程尽可能公平地共享CPU时间,又要考虑任务的优先级
- 需要考虑的问题:
- 进程有不同优先级
- 进程不能切换得太频繁,因为有进程上下文切换开销
- 两个相邻任务切换之间,切换时间不能太长
- 概念
- 不公平程度:本质与进程等待时间有关。如果CPU经常挑选最高等待时间的进程,不公平情况缓解,会均匀分布到系统的所有进程
- 就绪队列:管理活动进程的数据结构。完全公平调度器使用红黑树结构
调度算法
- 经典调度算法:对进程计算时间片。若所有进程时间片用尽则重新计算
- 完全公平调度:只考虑进程在就绪队列的等待时间,对CPU时间需求最严格的进程被调度执行(不需要传统的时间片)
- 数据结构:所有可运行进程按时间在红黑树中排序,等待CPU时间最长的进程是最左侧的项,下一次调度该进程
- 虚拟时钟:判断等待进程将获得多少CPU时间。要比实际时钟慢(假设就绪队列有4进程,虚拟时钟将以1/4速度运行)。
fair_clock
:就绪队列的虚拟时间wait_runtime
:进程的等待时间(也可以理解为不公平程度)fair_clock-wait_runtime
:进程的虚拟运行时间,用于红黑树排序
2.5.2 数据结构
调度方式:1)进程主动放弃CPU;2)内核通过周期性机制切换进程
1. task_struct成员
struct task_struct {
int static_prio; //静态优先级
int prio, normal_prio; //动态优先级
unsigned int rt_priority; //实时进程的优先级,不同于nice值
const struct sched_class *sched_class; //进程所属的调度器类
struct sched_entity se; //可调度实体,调度器依据实体操作各个task_struct
unsigned int policy; //进程的调度策略
cpumask_t cpus_allowed; //限制进程可以在哪些CPU上运行
struct list_head run_list; //用于循环实时调度器,维护包含个进程的运行表
unsigned int time_slice; //用于循环实时调度器,可使用CPU的剩余时间段
};
- 静态优先级:进程启动时分配的优先级,通常进程运行期间保持恒定(可以用
nice
和sched_setscheduler
修改) - 动态优先级:根据静态优先级和调度策略计算出的优先级,供调度器调度
- 进程的调度策略
SCHED_NORMAL
:用于普通进程,通过完全公平调度器(CFS)处理SCHED_BATCH
:用于非交互、CPU使用密集的批处理进程,通过CFS处理SCHED_IDLE
:用于相对权重最小的进程,通过CFS处理(注意:SCHED_IDLE不负责调度空闲进程,空闲进程由内核提供单独机制处理)SCHED_RR
:用于软实时进程,轮询调度,通过实时调度器类处理SCHED_FIFO
:用于软实时进程,先进先出调度,通过实时调度器类处理
2. 调度器类:理解为调度器基类
struct sched_class {
//向就绪队列添加进程
void (*enqueue_task) (struct rq *rq, struct task_struct *p, int wakeup);
//从就绪队列去除进程
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int sleep);
//进程自愿放弃CPU
void (*yield_task) (struct rq *rq);
//唤醒新进程,抢占当前进程
void (*check_preempt_curr) (struct rq *rq, struct task_struct *p);
//选择下一个要运行的进程(向进程提供CPU)
struct task_struct * (*pick_next_task) (struct rq *rq);
//用另一个进程代替当前运行的进程(进程撤销CPU)
void (*put_prev_task) (struct rq *rq, struct task_struct *p);
//进程调度策略变化时,调用set_curr_task
void (*set_curr_task) (struct rq *rq);
//每次激活周期性调度器时,由周期性调度器调用
void (*task_tick) (struct rq *rq, struct task_struct *p);
//每次新进程建立后,用task_new通知调度器,建立进程和调度器的关联
void (*task_new) (struct rq *rq, struct task_struct *p);
};
- 调度器类处理顺序:调度器之间层次结构是平坦的,优先级:实时进程>完全公平进程>空闲进程(CPU无事可做处于活动状态)
3. 就绪队列
- 就绪队列:管理活动进程的数据结构,各个活动进程只能出现在一个就绪队列中
- 系统的所有就绪队列位于
runqueues
数组中,每个元素对应系统的一个CPU。单处理器系统只有一个就绪队列
- 系统的所有就绪队列位于
- 队列负荷:与队列上当前活动进程的数目成正比,其中的各个进程又有优先级作为权重。影响就绪队列的虚拟时钟速度
struct rq {
unsigned long nr_running; //可运行进程数目
#define CPU_LOAD_IDX_MAX 5
unsigned long cpu_load[CPU_LOAD_IDX_MAX]; //用于跟踪此前的负荷度量
......
/* capture load from *all* tasks on this cpu: */
struct load_weight load; //队列负荷
struct cfs_rq cfs; //子就绪队列,用于完全公平调度器
struct rt_rq rt; //子就绪队列,用于完全公平调度器
struct task_struct *curr, *idle; //当前运行进程、空闲进程的task_struct
u64 clock, prev_clock_raw; //用于实现就绪队列自身的时钟
......
};
4. 调度实体
sched_entity
:调度器调度的是调度实体,不是进程(进程是实体的特例)- 每个
task_struct
有嵌入sched_entity
的实例
- 每个
struct sched_entity {
struct load_weight load; //指定权重,实体占队列总负荷的比例
struct rb_node run_node; //标准的数结点,使得实体可以在红黑树上排序
unsigned int on_rq; //当前实体是否在被调度
//以下用于完全公平调度器
u64 exec_start; //实体开始运行的时间
u64 sum_exec_runtime; //实体运行的时间
u64 vruntime; //虚拟时钟上流逝的时间数量
u64 prev_sum_exec_runtime; //实体被撤销CPU时,prev_sum_exec_runtime保存sum_exec_runtime值
};
2.5.3 处理优先级
调度器优先级task_struct->prio
prio
:调度器最终使用的优先级,范围0 ~ 139,值越低优先级越高- 0 ~ 99:供实时进程使用
- 100 ~ 139:代表普通进程的nice值,对应-20 ~ +19
prio
由static_prio
和normal_prio
彼此关联得到- 调度入口:
set_user_nice
和wake_up_new_task
,通过一行命令
p->prio = effective_prio(p);
static int effective_prio(struct task_struct *p) {
//计算normal_prio
p->normal_prio = normal_prio(p);
//如果是普通进程,返回normal_prio
if (!rt_prio(p->prio))
return p->normal_prio;
//如果是实时进程或进程已经提高到实时优先级,则保持优先级不变
return p->prio;
}
静态优先级task_struct->static_prio
static_prio
:静态优先级不会随时间改变,内核不会主动修改它。用户空间通过nice
命令设置,范围是-20 ~ +19- 内核为了转换
nice
和static_prio
,定义宏(其实就是范围映射,图2-14)
#define NICE_TO_PRIO(nice) (MAX_RT_PRIO + (nice) + 20)
#define PRIO_TO_NICE(prio) ((prio) - MAX_RT_PRIO - 20)
#define TASK_NICE(p) PRIO_TO_NICE((p)->static_prio)
/* 其中 */
#define MAX_USER_RT_PRIO 100
#define MAX_RT_PRIO MAX_USER_RT_PRIO
#define MAX_PRIO (MAX_RT_PRIO + 40)
#define DEFAULT_PRIO (MAX_RT_PRIO + 20)
- 注意:在fork出子进程时,子进程的静态优先级继承自父进程
归一化优先级task_struct->normal_prio
- 从代码可以看出,如果是实时进程,与
rt_priority
有关;如果是普通进程,与static_prio
一致
static inline int normal_prio(struct task_struct *p) {
int prio;
if (task_has_rt_policy(p)) //如果是实时进程
prio = MAX_RT_PRIO-1 - p->rt_priority; //prio = 99 - rt_priority
else //如果是普通进程
prio = __normal_prio(p); //prio = static_prio
return prio;
}
static inline int __normal_prio(struct task_struct *p) {
return p->static_prio;
}
实时优先级task_struct->rt_priority
rt_priority
:表示实时进程的优先级,范围0 ~ 99,值越大优先级越高(不同于静态优先级!)
prio = MAX_RT_PRIO-1 - p->rt_priority; //prio = 99 - rt_priority
优先级总结
负荷权重task_struct->se.load
- 负荷权重:进程的重要性除了与优先级外,还与负荷权重有关
struct load_weight {
unsigned long weight; //负荷权重
unsigned long inv_weight; //负荷权重倒数,2^32/weight
};
//内核优先级转换为权重值的因子
static const int 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,则多获得10%的CPU时间
- 假设有两个进程,进程A的nice=0,进程B的nice=1
- 进程A得到:1024/(1024+820)=55%的CPU份额
- 进程B得到:820/(1024+820)=45%的CPU份额 -> 产生了10%的差额
// 计算负荷权重
static void set_load_weight(struct task_struct *p) {
// 实时进程的负荷权重是普通仅的2倍(本书是2.6.24内核,后续内核规则有变)
if (task_has_rt_policy(p)) {
p->se.load.weight = prio_to_weight[0] * 2; //nice=-20的权重*2
p->se.load.inv_weight = prio_to_wmult[0] >> 1;
return;
}
// SCHED_IDLE进程得到的权重最少
if (p->policy == SCHED_IDLE) {
p->se.load.weight = WEIGHT_IDLEPRIO; //2
p->se.load.inv_weight = WMULT_IDLEPRIO; //1<<31
return;
}
// 根据因子表,将静态优先级转为负荷权重
p->se.load.weight = prio_to_weight[p->static_prio - MAX_RT_PRIO];
p->se.load.inv_weight = prio_to_wmult[p->static_prio - MAX_RT_PRIO];
}
2.5.4 核心调度器
1. 周期性调度器scheduler_tick()
:内核按照频率HZ
自动调用该函数
void scheduler_tick(void) {
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;
u64 next_tick = rq->tick_timestamp + TICK_NSEC;
spin_lock(&rq->lock);
// 1. 处理就绪队列时钟更新
__update_rq_clock(rq); //增加当前rq的时间戳rq->clock
......
update_cpu_load(rq); bbbb //更新rq->cpu_load[]
// 2. 使用调度器调度
if (curr != rq->idle) /* FIXME: needed? */
curr->sched_class->task_tick(rq, curr);
spin_unlock(&rq->lock);
......
}
- 上面代码片段中,
update_cpu_load
比较有意思,使用一些取平均值的技巧来更新CPU负载的。cpu_load分为5个级别,保证负荷数组不会呈现太多不连续跳变:第0级负荷数组变更速度最快,第5级负荷数组变更速度较慢。举个例子:
当前CPU负荷 | 0 | 10 | 10 | 10 | 10 | 10 | 10 |
---|---|---|---|---|---|---|---|
cpu_load[0] | 0 | 10 | 10 | 10 | 10 | 10 | 10 |
cpu_load[1] | 0 | 5 | 8 | 9 | 10 | 10 | 10 |
cpu_load[2] | 0 | 3 | 5 | 7 | 8 | 9 | 10 |
/*
* Update rq->cpu_load[] statistics. This function is usually called every
* scheduler tick (TICK_NSEC).
*/
static void update_cpu_load(struct rq *this_rq) {
unsigned long this_load = this_rq->load.weight;
int i, scale;
this_rq->nr_load_updates++;
/* Update our load: */
for (i = 0, scale = 1; i < CPU_LOAD_IDX_MAX; i++, scale += scale) {
unsigned long old_load, new_load;
/* scale is effectively 1 << i now, and >> i divides by scale */
old_load = this_rq->cpu_load[i];
new_load = this_load;
/*
* Round up the averaging division if load is increasing. This
* prevents us from getting stuck on 9 if the load is 10, for
* example.
*/
if (new_load > old_load)
new_load += scale-1;
this_rq->cpu_load[i] = (old_load*(scale-1) + new_load) >> i;
}
}
2. 主调度器schedule()
:CPU主动分配给另一进程
/*
* schedule() is the main scheduler function.
*/
asmlinkage void __sched schedule(void)
{
......
need_resched:
......
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr; //prev指向当前进程
......
__update_rq_clock(rq); //更新就绪队列时钟
clear_tsk_need_resched(prev); //清除冲调度标志TIF_NEED_RESCHED
//就绪队列移除当前进程,这里会调用到sched_class->dequque_task
//如果进程是可中断睡眠状态但现在收到信号,必须提升为就绪状态
if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {
if (unlikely((prev->state & TASK_INTERRUPTIBLE) &&
unlikely(signal_pending(prev)))) {
prev->state = TASK_RUNNING;
} else {
deactivate_task(rq, prev, 1);
}
switch_count = &prev->nvcsw;
}
......
//通知调度器类,当前运行的进程将要被另一个进程代替
prev->sched_class->put_prev_task(rq, prev);
//调度器选择下一个应该执行的进程
next = pick_next_task(rq, prev);
sched_info_switch(prev, next);
//准备执行硬件级的进程切换
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
++*switch_count;
context_switch(rq, prev, next); //上下文切换
}
......
//检测TIF_NEED_RESCHED是否设置,如设置重新开始搜索一个新进程
if (unlikely(test_thread_flag(TIF_NEED_RESCHED)))
goto need_resched;
}
3. 与fork的交互schedule_fork()
:使用fork
系统调用时,子进程调度实体数据结构、确定进程的动态优先级
void sched_fork(struct task_struct *p, int clone_flags)
{
int cpu = get_cpu();
//初始化调度字段、建立数据结构(task_struct->se)、子进程设为TASK_RUNNING
__sched_fork(p);
......
//使用父进程的normal_prio作为子进程的动态优先级prio
p->prio = current->normal_prio;
//如果优先级不在实时范围,默认调度器是完全公平调度CFS
if (!rt_prio(p->prio))
p->sched_class = &fair_sched_class;
......
}
4. 上下文切换context_switch():其核心是switch_mm()
和switch_to()
/*
* context_switch - switch to the new MM and the new
* thread's register state.
*/
static inline void
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
struct mm_struct *mm, *oldmm;
//上下文切换准备、mm指向下个进程的页表、oldmm指向当前进程的活动页表
prepare_task_switch(rq, prev, next);
mm = next->mm;
oldmm = prev->active_mm;
......
//加载页表、刷出TLB(部分或全部)、向MMU提供新的信息
if (unlikely(!mm)) {
next->active_mm = oldmm;
atomic_inc(&oldmm->mm_count);
enter_lazy_tlb(oldmm, next);
} else {
switch_mm(oldmm, mm, next);
}
//如果当前进程是内核线程(prev->mm == NULL),断开与借用的地址空间的联系
if (unlikely(!prev->mm)) {
prev->active_mm = NULL;
rq->prev_mm = oldmm;
}
......
//切换寄存器和内核栈
switch_to(prev, next, prev);
//内存屏障,确保switch_to和finish_task_switch的执行顺序不变
barrier();
//清理工作,正确地释放锁等(这里可能switch_to到随机调度的进程,甚至是别的CPU上的进程,所以清理工作清理的是当前的活跃进程,也要重新计算rq)
finish_task_switch(this_rq(), prev);
}
5. switch_to
- 切换寄存器和内核栈,通常使用汇编语言完成
<asm-x86/system_32.h>
/*
* Saving eflags is important. It switches not only IOPL between tasks,
* it also protects other tasks from NT leaking through sysenter etc.
*/
#define switch_to(prev,next,last) do { \
unsigned long esi,edi; \
asm volatile("pushfl\n\t" /* Save flags */ \
"pushl %%ebp\n\t" \
"movl %%esp,%0\n\t" /* save ESP */ \
"movl %5,%%esp\n\t" /* restore ESP */ \
"movl $1f,%1\n\t" /* save EIP */ \
"pushl %6\n\t" /* restore EIP */ \
"jmp __switch_to\n" \
"1:\t" \
"popl %%ebp\n\t" \
"popfl" \
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), \
"=a" (last),"=S" (esi),"=D" (edi) \
:"m" (next->thread.esp),"m" (next->thread.eip), \
"2" (prev), "d" (next)); \
} while (0)
【说明】switch_to
:其本质是保存旧进程eflags
、ESP
和EIP
寄存器到当前内核栈prev->thread
中,加载新进程eflags
、ESP
和EIP
输出参数:
%0=prev->thread.esp,内存变量
%1=prev->thread.eip,内存变量
%2=eax=last
%3=esi
%4=edi
输入参数:
%5=next->thread.esp,内存变量
%6=next->thread.eip,内存变量
%7=eax=prev
%8=next
函数内容:
pushfl //保存eflags
pushl %ebp //保存栈底指针EBP
movl %esp, %prev->thread.esp //保存栈顶指针ESP
movl %next->thread.esp, %esp //加载新进程ESP
movl $1f, %prev->thread.eip //保存旧进程EIP为标号1的位置,后续切换后从1执行
pushl %next->thread.eip //加载新进程EIP
jmp __switch_to //完成硬件的上下文切换
1: popl %ebp //恢复新进程栈底指针EBP
popfl //恢复新进程eflags
【备注】switch_to
函数有个有意思的点,是传了3个实参:prev, next, last。为什么要传last?
- 书中例子:三个进程轮转调度会出现问题
- 进程A->进程B:switch_to后,内核栈A记录prev=A,next=B
- 进程B->进程C:switch_to后,内核栈B记录prev=B,next=C
- 进程C->进程A:switch_to后,内核栈C记录prev=C,next=A;内核栈A记录prev=A,next=B。此时恢复内核栈A,内核为了直到进程A前运行的是进程C,加入了last
- 其本质是:
prev
作为宏传入,switch_to
前后prev
由于内核栈重载发生改变,所以引入last
变量来得到prev
的值,用于finish_task_switch
的清理工作