【深入Linux内核架构笔记】第二章 进程调度(1)

914 阅读11分钟

2.5 调度器的实现

2.5.1 概观

进程调度:CPU同一时刻只能执行一个任务。各个进程尽可能公平地共享CPU时间,又要考虑任务的优先级

  • 需要考虑的问题:
    • 进程有不同优先级
    • 进程不能切换得太频繁,因为有进程上下文切换开销
    • 两个相邻任务切换之间,切换时间不能太长
  • 概念
    • 不公平程度:本质与进程等待时间有关。如果CPU经常挑选最高等待时间的进程,不公平情况缓解,会均匀分布到系统的所有进程
    • 就绪队列:管理活动进程的数据结构。完全公平调度器使用红黑树结构

调度算法

  • 经典调度算法:对进程计算时间片。若所有进程时间片用尽则重新计算
  • 完全公平调度:只考虑进程在就绪队列的等待时间,对CPU时间需求最严格的进程被调度执行(不需要传统的时间片)
    • 数据结构:所有可运行进程按时间在红黑树中排序,等待CPU时间最长的进程是最左侧的项,下一次调度该进程
    • 虚拟时钟:判断等待进程将获得多少CPU时间。要比实际时钟慢(假设就绪队列有4进程,虚拟时钟将以1/4速度运行)。
      • fair_clock:就绪队列的虚拟时间
      • wait_runtime:进程的等待时间(也可以理解为不公平程度)
      • fair_clock-wait_runtime:进程的虚拟运行时间,用于红黑树排序

image.png

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的剩余时间段
};
  • 静态优先级:进程启动时分配的优先级,通常进程运行期间保持恒定(可以用nicesched_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

image.png

  • priostatic_prionormal_prio彼此关联得到
  • 调度入口:set_user_nicewake_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
  • 内核为了转换nicestatic_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

优先级总结

image.png

负荷权重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负荷0101010101010
cpu_load[0]0101010101010
cpu_load[1]0589101010
cpu_load[2]03578910
/*
 * 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:其本质是保存旧进程eflagsESPEIP寄存器到当前内核栈prev->thread中,加载新进程eflagsESPEIP

输出参数:
%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的清理工作

image.png