Linux系统核心调度器——主调度器schedule函数详解

日期 内核版本 架构 作者 内容
2019-3-23 Linux-2.6.32

X86

Bystander Linux进程调度

1.绪论

《Linux系统进程调度——调度架构详细分析》一文中详细分析了调度器运行原理及过程,本文将详细分析主调度器。

1.1Linux进程调度

内存中保存了对每个进程的唯一描述, 并通过若干数据结构与其他进程连接起来。调度器面对其任务是在程序之间共享CPU时间, 创造并行执行的错觉, 该任务分为两个不同的部分, 其中一个是调度策略, 另外一个是上下文切换.

两种方法来激活调度:

  1. 进程直接放弃CPU
  2. 通过周期性机制, 以固定的频率检测是否有必要调度

当前linux的系统由两个调度器组成:主调度器和周期性调度器(两者又统称为通用调度器(generic scheduler)或核心调度器(core scheduler))

2.主调度器schedule()函数

schedule就是主调度器的函数, 在内核中,如果要将CPU分配给与当前活动进程不同的另一个进程, 都会直接或间接调用主调度器函数schedule.例如down(struct semaphore *sem)函数到最后也是调用schedule。

该函数完成如下工作:

  1. 确定当前运行队列, 并保存一个指向当前活动的task_struct指针
  2. 关闭内核抢占后完成内核调度
  3. 恢复内核抢占, 检查当前进程是否设置了重调度标志TLF_NEDD_RESCHED, 如果被其他进程设置了TIF_NEED_RESCHED标志, 则函数重新执行进行调度

schedule函数框架如下:

/*
 * schedule() is the main scheduler function.
 * schedule()函数主要功能就是用另外一个进程来
 * 替换当前正在执行的进程
 */
asmlinkage void __sched schedule(void)
{
	struct task_struct *prev, *next;
	unsigned long *switch_count;
	struct rq *rq;
	int cpu;

need_resched:
	/*
	 * 禁用内核抢占
	 */
	preempt_disable();
	
	/*
	 * 获取当前CPU核心ID
	 */
	cpu = smp_processor_id();
	
	/*
	 * 通过当前CPU核心ID获取正在运行队列数据结构
	 */
	rq = cpu_rq(cpu);
	
	/*
	 * 标记不同的state度过quiescent state,
	 * 这个函数需要学习RCU相关知识,有兴趣同学自己学习,
	 * RCU在linux内核中时很重要技术
	 */
	rcu_sched_qs(cpu);

	/*
	 * 把当前进程赋给prev
	 */
	prev = rq->curr;

	/*
	 * 将截止目前的上下文切换次数赋给switch_count
	 */
	switch_count = &prev->nivcsw;

	/*
	 * 释放大内核锁,schedule()必须要保证prev不能占中大内核锁
	 */
	release_kernel_lock(prev);
need_resched_nonpreemptible:

	/*
	 * 如果禁止内核抢占,而又调用了cond_resched就会出错
	 * 这个函数就是用来捕获该错误的
	 */
	schedule_debug(prev);

	/*
	 * 取消rq中hrtick_timer
	 */
	if (sched_feat(HRTICK))
		hrtick_clear(rq);
	
	/*
	 * 采用自旋锁,锁住rq,保护运行队列
	 */
	spin_lock_irq(&rq->lock);
	
	/*
	 * 更新就绪队列的时钟
	 */
	update_rq_clock(rq);

	/*
	 * 清除prev需要调度标志TIF_NEED_RESCHED,
	 * 避免进入就绪队列中
	 */
	clear_tsk_need_resched(prev);
	/*
	 * 检查prev状态,如果不是可运行状态,
	 * 且prev没有在内核态被抢占。
	 */
	if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {
		/*
		 * 检查prev是否是阻塞挂起,且状态为TASK_INTERRUPTIBLE
		 * 就把prev状态设置为TASK_RUNNING。
		 */
		if (unlikely(signal_pending_state(prev->state, prev)))
			/*
			 * 这里设置为TASK_RUNNING,因为prev进程中有信号需要处理,
			 * 不能从运行对列中删除,否则信号处理不了,会影响其余进程。
			 */
			prev->state = TASK_RUNNING;
		else
			/*
			 * 把prev从运行队列中删除
			 */
			deactivate_task(rq, prev, 1);
		switch_count = &prev->nvcsw;
	}
	/*
	 * 通知调度器,即将发生进程切换
	 */
	pre_schedule(rq, prev);
	
	/*
	 * 如果运行队列中没有可运行队列,
	 * 则从另一个运行队列迁移可运行进程到本地队列中来。
	 */
	if (unlikely(!rq->nr_running))
		idle_balance(cpu, rq);
	
	/*
	 * 通知调度器,即将用另一个进程替换当前进程
	 */
	put_prev_task(rq, prev);
	
	/*
	 * 选择下一个进程
	 */
	next = pick_next_task(rq);

	/*
	 * 判断选择出的下一个进程是否是当前进程
	 */
	if (likely(prev != next)) {
		
		/*
		 * 计算prev和next进程运行时间等参数
		 */
		sched_info_switch(prev, next);
		
		/*
		 * 从调度程序调用以删除当前任务的事件,同时禁用中断
		 * 停止每个事件并更新事件->计数中的事件值。
		 */
		perf_event_task_sched_out(prev, next, cpu);
	
		/*  
		 * 队列切换次数更新
		 */
		rq->nr_switches++;
		
		/*  
		 * 将next标记为队列的curr进程  
		 */
		rq->curr = next;

		/*
		 * 进程切换次数更新  
		 */
		++*switch_count;
		/*
		 * 进程之间上下文切换,两个进程切换就在此处发生
		 * 两个进程切换两大部分:1.prev到next虚拟地址空间的映射,
		 * 由于内核虚拟地址空间是不许呀切换的, 
		 * 因此切换的主要是用户态的虚拟地址空间。
		 * 2.保存、恢复栈信息和寄存器信息。
		 */
		context_switch(rq, prev, next); /* unlocks the rq */
		/*
		 * the context switch might have flipped the stack from under
		 * us, hence refresh the local variables.进程切换了,刷新局部变量。
		 */
		cpu = smp_processor_id();
		rq = cpu_rq(cpu);
	} else
		/*
		 * 释放rq锁
		 */
		spin_unlock_irq(&rq->lock);
	/*
	 * 通知调度器,完成了进程切换
	 */
	post_schedule(rq);
	/*
	 * 重新获取大内核锁,如果获取不到则需要重新调度
	 */
	if (unlikely(reacquire_kernel_lock(current) < 0))
		goto need_resched_nonpreemptible;
	/*
	 * 重新使能内核抢占
	 */
	preempt_enable_no_resched();
	/*
  	 * 检查其余进程已经设置当前进程的TIF_NEED_RESCHED标志,
  	 * 如果设置了需要进行重新调度。
	 */
	if (need_resched())
		goto need_resched;
}

3.schedule()函数中进程切换关键函数

以上就是结合schedule函数实际代码进行解释,笔者能力有限进行一些浅薄的解释,以下是对一些关键函数进行讲解,

  1. idle_balance(int this_cpu, struct rq *this_rq)(点解查看),如果当前CPU运行队列无可运行进程则进入IDLE 状态,将调用idle_balance()进而调用load_balance_newidle函数实现多处理器运行队列平衡功能。
  2. pick_next_task(struct rq *rq),从CPU运行队列中选择一个进程。
  3. context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next),当pick_next_task()选择好进程后,由context_switch()进行进程切换,也就是进程上下文切换。

3.1pick_next_task()函数

pick_next_task()函数从调度类中选择一个进程,而在Linux中pick_next_task()不会直接操作进程而是操作调度实体。

在Linux-2.6.32中有三个调度类优先级从高到底为:rt_sched_class,fair_sched_class,idle_sched_class。pick_next_task()将从以上三个调度类中先从调度类优先级最高sched_class_highest的rt_sched_class选择优先级最高的进程,若rt_sched_class中无可运行进程再从低优先级的fair_sched_class中选择,依次类推。

下面对pick_next_task()函数进行源码分析:

/*
 * Pick up the highest-prio task:
 */
static inline struct task_struct *
pick_next_task(struct rq *rq)
{
	const struct sched_class *class;
	struct task_struct *p;

	/*
	 * Optimization: we know that if all tasks are in
	 * the fair class we can call that function directly:
     * 根据注释可以明显看出,若可运行队列中进程数量与fair类中可运行进程
     * 数相等则直接在fair类中进行进程的挑选。是因为在Linux绝大多数进程都是
     * 普通进程数据fair类,这里减少在rt实时类中进行进程的选择减少开销。
	 */
	if (likely(rq->nr_running == rq->cfs.nr_running)) {
		p = fair_sched_class.pick_next_task(rq);
		if (likely(p))
			return p;
	}
    /*从优先级最高的调度类中开始选择*/
	class = sched_class_highest;
	for ( ; ; ) {
		p = class->pick_next_task(rq);
		if (p)
			return p;
		/*
		 * Will never be NULL as the idle class always
		 * returns a non-NULL p:
		 */
        /*如果优先级最高的调度类中没有可运行进程则进行优先级较低的类中进行选择进程*/
		class = class->next;
	}
}

在Linux-2.6.32中sched_class_highest是rt_sched_class,在 \kernel \sched.c中定义。

#define sched_class_highest (&rt_sched_class)

在每个调度类中将会定义比它自己低优先级的调度类,为了在pick_next_task()函数中class = class->next处进行调度。

在rt_sched_class中定义当rt_sched_class无可运行进程时就从fair_sched_class中挑选可运行进程:

static const struct sched_class rt_sched_class = {
	.next			= &fair_sched_class,
    ...
};

在fair_sched_class中定义当fair_sched_class无可运行进程时就从idle_sched_class中挑选可运行进程:

static const struct sched_class fair_sched_class = {
	.next			= &idle_sched_class,
    ...
};

在idle_sched_class中定义.next为NULL:

static const struct sched_class idle_sched_class = {
	/* .next is NULL */
	/* no enqueue/yield_task for idle tasks */
    ...
};

如果rt_sched_class和fair_sched_class中都没有可运行的进程时将运行idle进程,如果有可运行进程则按照优先级高到低进行进程选择。

3.2context_switch()进程上下文切换

上下文切换,有时也称做进程切换或任务切换,是指CPU 从一个进程或线程切换到另一个进程或线程。在操作系统中,CPU切换到另一个进程需要保存当前进程的状态并恢复另一个进程的状态:当前运行任务转为就绪(或者挂起、删除)状态,另一个被选定的就绪任务成为当前任务。上下文切换包括保存当前任务的运行环境,恢复将要运行任务的运行环境。

在三种情况下可能会发生上下文切换:中断处理,多任务处理,用户态切换。在中断处理中,其他程序”打断”了当前正在运行的程序。当CPU接收到中断请求时,会在正在运行的程序和发起中断请求的程序之间进行一次上下文切换。在多任务处理中,CPU会在不同程序之间来回切换,每个程序都有相应的处理时间片,CPU在两个时间片的间隔中进行上下文切换。对于一些操作系统,当进行用户态切换时也会进行一次上下文切换,虽然这不是必须的。

进程上下文切换消耗:

  1. 上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
  2. 一个线程可以运行在一个专用处理器上,也可以跨处理器。由单个处理器服务的线程都有处理器关联(Processor Affinity),这样会更加有效。在另一个处理器内核抢占和调度线程会引起缓存丢失,作为缓存丢失和过度上下文切换的结果要访问本地内存。对于要处理一些高速通信的进程或线程最好绑定CPU避免“跨核上下文切换”带来损耗。

3.2.1context_switch()实现

/*
 * 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;

	prepare_task_switch(rq, prev, next);
	trace_sched_switch(rq, prev, next);
	mm = next->mm;
	oldmm = prev->active_mm;
	/*
	 * For paravirt, this is coupled with an exit in switch_to to
	 * combine the page table reload and the switch backend into
	 * one hypercall.
	 */
	arch_start_context_switch(prev);

	if (unlikely(!mm)) {
		next->active_mm = oldmm;
		atomic_inc(&oldmm->mm_count);
		enter_lazy_tlb(oldmm, next);
	} else
		switch_mm(oldmm, mm, next);

	if (unlikely(!prev->mm)) {
		prev->active_mm = NULL;
		rq->prev_mm = oldmm;
	}
	/*
	 * Since the runqueue lock will be released by the next
	 * task (which is an invalid locking op but in the case
	 * of the scheduler it's an obvious special-case), so we
	 * do an early lockdep release here:
	 */
#ifndef __ARCH_WANT_UNLOCKED_CTXSW
	spin_release(&rq->lock.dep_map, 1, _THIS_IP_);
#endif

	/* Here we just switch the register state and the stack. */
	switch_to(prev, next, prev);

	barrier();
	/*
	 * this_rq must be evaluated again because prev may have moved
	 * CPUs since it called schedule(), thus the 'rq' on its stack
	 * frame will be invalid.
	 */
	finish_task_switch(this_rq(), prev);
}

由上面代码可以看出内核的进程切换由两部分实现:

  1. 切换全局页目录以安装一个新的地址空间,由switch_mm()函数完成。
  2. 切换内核堆栈和硬件上下文。硬件上下文提供了内核执行新进程所需要的所有信息,包含CPU寄存器,由switch_to()完成。

4结语

本文结合代码分析了schedule函数的执行过程以及实现方式,由于context_switch()函数中的switch_mm()和switch_to()较为晦涩这里不再详细分析。有兴趣的同学可以在闲暇时间学习研究。

发布了15 篇原创文章 · 获赞 21 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/weixin_42092278/article/details/88778435