linux操作系统:主动调度与抢占式调度

这里,可以看到linux给调度准备了非常多的数据结构,那么调度到底是怎么发生的呢?

所谓任务调度,其实就是一个人在做A任务,在某个时刻,换成做B任务去了。发生这种情况,主要有两种调度方式:

  • 方式一:A任务做着做着,发现里面有一条指令sleep,就是要休息一下,或者在等待某个IO时间。那就没有办法,就要主动让出CPU,然后就可以开始做B任务了
  • 方式二:A任务做着做着,旷日持久,实在受不了了。项目经理介入了,说这个任务A先听听,B任务也要做一下,要不然B任务就该投诉了

主动调度

我们先来看看方式一,主动调度。

  • 第一个例子是Btrfs(Btrfs是一种文件系统)写入一个块设备。写入需要一段时间,这段时间用不上CPU,还不如主动让给其他进程
static void btrfs_wait_for_no_snapshoting_writes(struct btrfs_root *root)
{
......
	do {
		prepare_to_wait(&root->subv_writers->wait, &wait,
				TASK_UNINTERRUPTIBLE);
		writers = percpu_counter_sum(&root->subv_writers->counter);
		if (writers)
			schedule();
		finish_wait(&root->subv_writers->wait, &wait);
	} while (writers);
}
  • 第二个例子是,从tap网络设备等待一个读取。tap网络设备是虚拟机使用的网络设备,当没有数据到来的时候,它也需要等待,所以也会选择把CPU让给其他进程:
static ssize_t tap_do_read(struct tap_queue *q,
			   struct iov_iter *to,
			   int noblock, struct sk_buff *skb)
{
......
	while (1) {
		if (!noblock)
			prepare_to_wait(sk_sleep(&q->sk), &wait,
					TASK_INTERRUPTIBLE);
......
		/* Nothing to read, let's sleep */
		schedule();
	}
......
}

计算机主要处理计算、网络、存储三个方面。计算主要是CPU和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出CPU,就像上面一样,选择主动调度schedule()。

那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());
}

可以看到其主要逻辑在__schedule中实现

在任务队列中取下一个任务

static void __sched notrace __schedule(bool preempt)
{
	struct task_struct *prev, *next;
	unsigned long *switch_count;
	struct rq_flags rf;
	struct rq *rq;
	int cpu;
 
 
	cpu = smp_processor_id();
	rq = cpu_rq(cpu);
	prev = rq->curr;
......

首先,在当前的CPU上,我们取出任务队列rq。

task_struct *prev指向这个CPU的任务队列上面正在运行的那个进程curr。为啥是prev?因为一旦将来它被切换下来,那它就成为了前任了。

接下来代码如下:

next = pick_next_task(rq, prev, &rf);
clear_tsk_need_resched(prev);
clear_preempt_need_resched();

第二步,获取下一个任务,task_struct *next指向下一个任务。

pick_next_task实现如下:

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	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, but only if the @prev task wasn't of a higher scheduling class, because otherwise those loose the opportunity to pull in more work from other CPUs.
	 */
	if (likely((prev->sched_class == &idle_sched_class ||
		    prev->sched_class == &fair_sched_class) &&
		   rq->nr_running == rq->cfs.h_nr_running)) {
		p = fair_sched_class.pick_next_task(rq, prev, rf);
		if (unlikely(p == RETRY_TASK))
			goto again;
		/* Assumes fair_sched_class->next == idle_sched_class */
		if (unlikely(!p))
			p = idle_sched_class.pick_next_task(rq, prev, rf);
		return p;
	}
again:
	for_each_class(class) {
		p = class->pick_next_task(rq, prev, rf);
		if (p) {
			if (unlikely(p == RETRY_TASK))
				goto again;
			return p;
		}
	}
}

我们来看again这里,就是这里说过的依次调用类。但是这里有一个优化,因为大部分进程是普通进程,所以大部分情况下会调用上面的逻辑,调用的是fair_sched_class.pick_next_task

根据这里对于fair_sched_class的定义,它调用的是pick_next_task_fair,代码如下:

tatic struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	struct cfs_rq *cfs_rq = &rq->cfs;
	struct sched_entity *se;
	struct task_struct *p;
	int new_tasks;

对于CFS调度类,取出相应的队列 cfs_rq,也就是这里说过的红黑树

		struct sched_entity *curr = cfs_rq->curr;
		if (curr) {
			if (curr->on_rq)
				update_curr(cfs_rq);
			else
				curr = NULL;
......
		}
		se = pick_next_entity(cfs_rq, curr);

取出当前正在运行的curr,如果依然是可运行的状态,也就是处于进程就绪状态,则调用update_curr(cfs_rq);更新vruntime。

接着,pick_next_entity(cfs_rq, curr)从红黑树里面,取出最左边的一个节点

	p = task_of(se);
 
 
	if (prev != p) {
		struct sched_entity *pse = &prev->se;
......
		put_prev_entity(cfs_rq, pse);
		set_next_entity(cfs_rq, se);
	}
 
 
	return p

task_of得到下一个调度实体对应的task_struct,如果发现继任和前任不一样,这就说明有一个更需要运行的进程了,就需要更新红黑树了。前面前任的vruntime更新过了,put_prev_entity放回红黑树,会找到相应的位置,然后set_next_entity将继任者设为当前任务。

第三步,当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行:

if (likely(prev != next)) {
		rq->nr_switches++;
		rq->curr = next;
		++*switch_count;
......
		rq = context_switch(rq, prev, next, &rf);

当前任务上下文切换为下一个任务

上下文切换主要干两件事情,一是切换进程空间,也就是虚拟内存;而是切换寄存器和CPU上下文。

我们先来看context_switch的实现:

/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
	       struct task_struct *next, struct rq_flags *rf)
{
	struct mm_struct *mm, *oldmm;
......
	mm = next->mm;
	oldmm = prev->active_mm;
......
	switch_mm_irqs_off(oldmm, mm, next);
......
	/* Here we just switch the register state and the stack. */
	switch_to(prev, next, prev);
	barrier();
	return finish_task_switch(prev);
}

这里首先是内存空间的切换。

然后switch_to进行寄存器和栈的切换,它调用到了__switch_to_asm。这是一段汇编代码,主要用于栈的切换。

对于32为操作系统来讲,切换的是栈顶指针esp

/*
 * %eax: prev task
 * %edx: next task
 */
ENTRY(__switch_to_asm)
......
/* switch stack */
movl %esp, TASK_threadsp(%eax)
movl TASK_threadsp(%edx), %esp
......
jmp __switch_to
END(__switch_to_asm)

对于64位操作系统来讲,切换的是栈顶指针rsp:

/*
 * %rdi: prev task
 * %rsi: next task
 */
ENTRY(__switch_to_asm)
......
/* switch stack */
movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp
......
jmp __switch_to
END(__switch_to_asm)

最终,都返回了 __switch_to 这个函数

抢占式调度

第二种方式就是抢占式调度。什么情况下会发生抢占呢?

标记应该抢占

最常见的现象就是一个进程执行时间太长了,就会切换到另一个进程。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,这是个很好的方式,可以查看是否需要抢占时间点。

时钟中断处理函数会调用scheduler_tick(),它的代码如下:

void scheduler_tick(void)
{
	int cpu = smp_processor_id();
	struct rq *rq = cpu_rq(cpu);
	struct task_struct *curr = rq->curr;
......
	curr->sched_class->task_tick(rq, curr, 0);
	cpu_load_update_active(rq);
	calc_global_load_tick(rq);
......
}

这个函数先获取当前CPU的运行队列,然后去这个队列上获取正在运行中的进程的task_struct,然后调用这个task_struct的调度类task_tick函数,顾名思义这个函数就是用来处理时钟事件的。

如果当前运行的进程是普通进程,调度类为fair_sched_class,调用的处理时钟的函数为task_tick_fair,实现如下:

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
	struct cfs_rq *cfs_rq;
	struct sched_entity *se = &curr->se;
 
 
	for_each_sched_entity(se) {
		cfs_rq = cfs_rq_of(se);
		entity_tick(cfs_rq, se, queued);
	}
......
}

根据当前进程的task_struct,找到对应的调度实体sched_entity和cfs_rq队列,调用entity_tick:

static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
	update_curr(cfs_rq);
	update_load_avg(curr, UPDATE_TG);
	update_cfs_shares(curr);
.....
	if (cfs_rq->nr_running > 1)
		check_preempt_tick(cfs_rq, curr);
}

update_curr更新当前进程的vruntime,然后调用check_preempt_tick,顾名思义,就是检测是否是时候被抢占了。

static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
	unsigned long ideal_runtime, delta_exec;
	struct sched_entity *se;
	s64 delta;
 
 
	ideal_runtime = sched_slice(cfs_rq, curr); // 一个调度周期中,这个进程应该运行的实际时间
	delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime; // 这次调度占用实际时间
	if (delta_exec > ideal_runtime) {	// 已经调度的时间 > 期望调度的时间
		resched_curr(rq_of(cfs_rq)); // 这个进程应该被抢占
		return;
	}
......
	se = __pick_first_entity(cfs_rq);  // 取出红黑树中最小的进程
	delta = curr->vruntime - se->vruntime; // 实际的vruntime 
	if (delta < 0)
		return;
	if (delta > ideal_runtime)
		resched_curr(rq_of(cfs_rq));  // 这个进程应该被抢占
}

check_preempt_tick先是调用sched_slice函数计算出的ideal_runtime ,它是一个调度周期中,这个进程应该运行的实际时间。

sum_exec_runtime 指进程总共执行的实际时间,prev_sum_exec_runtime 指上次该进程被调度时已经占用的实际时间。每次在调度一个新的进程时都会把它的 se->prev_sum_exec_runtime = se->sum_exec_runtime,所以 sum_exec_runtime-prev_sum_exec_runtime 就是这次调度占用实际时间。如果这个时间大于 ideal_runtime,则应该被抢占了。

除了这个条件之外,还会通过 __pick_first_entity 取出红黑树中最小的进程。如果当前进程的 vruntime 大于红黑树中最小的进程的 vruntime,且差值大于 ideal_runtime,也应该被抢占了。

当发现当前进程应该被抢占,不能直接把它踢下来,而是把它标记为应该被抢占。为什么呢?因为所有进程切换都必须等待正在运行的进程调用__schedule才行,所以这里只能先标记一下。

标记一个进程应该被抢占,都是调用resched_curr,它会调度set_tsk_need_resched,标记进程应该被抢占,但是此时此刻,并不真的抢占,而是打上一个标签 TIF_NEED_RESCHED

static inline void set_tsk_need_resched(struct task_struct *tsk)
{
	set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
}

另一个可能抢占的场景是当一个进程被唤醒的时候

我们前面说过,当一个进程在等待一个IO的时候,会主动放弃CPU。但是当IO到来的时候,进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于CPU上的当前进程,就会触发抢占。try_to_wake_up() 调用ttwu_queue将这个唤醒的任务添加到队列当中。ttwu_queue 再调用ttwu_do_activate 激活这个任务。ttwu_do_activate 调用ttwu_do_wakeup。这里面调用了check_preempt_curr 检测是否应该发生抢占。如果应该发生抢占,也不是直接踢走当前进程,而是也将当前进程标记为应该被抢占。

static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
			   struct rq_flags *rf)
{
	check_preempt_curr(rq, p, wake_flags);
	p->state = TASK_RUNNING;
	trace_sched_wakeup(p);

到这里,你会发现,抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了,但是真正的抢占动作并没有发生。

抢占的时机

真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一次__schedule。

你可以想象,不可能某个进程代码运行这,突然要去调用__schedule,代码里面不可能这么写,所以一定要规划几个时机,这个时机分为用户态和内核态

用户态的抢占时机

对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机

  • 在系统调用的时候,64为的系统调用的链路为 do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop。exit_to_usermode_loop 里面做些什么呢?
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
	while (true) {
		/* We have work to do. */
		local_irq_enable();
 
 
		if (cached_flags & _TIF_NEED_RESCHED)
			schedule();
......
	}
}
  • 可以看到在exit_to_usermode_loop函数中,上面打的标记起了作用,如果被打了_TIF_NEED_RESCHED,调度shcedule进行调度:选择一个进程让出CPU,做上下文切换。

对于用户态的进程来讲,从中断中返回的那个时刻,也是一个被抢占的时机(又是一段汇编语言代码,重点领会它的意思就行,不要纠结每一行都看懂。)

  • 在 arch/x86/entry/entry_64.S 中有中断的处理过程
common_interrupt:
        ASM_CLAC
        addq    $-0x80, (%rsp) 
        interrupt do_IRQ
ret_from_intr:
        popq    %rsp
        testb   $3, CS(%rsp)
        jz      retint_kernel
/* Interrupt came from user space */
GLOBAL(retint_user)
        mov     %rsp,%rdi
        call    prepare_exit_to_usermode
        TRACE_IRQS_IRETQ
        SWAPGS
        jmp     restore_regs_and_iret
/* Returning to kernel space */
retint_kernel:
#ifdef CONFIG_PREEMPT
        bt      $9, EFLAGS(%rsp)  
        jnc     1f
0:      cmpl    $0, PER_CPU_VAR(__preempt_count)
        jnz     1f
        call    preempt_schedule_irq
        jmp     0b
  • 中断处理调用的是do_IRQ函数,中断完毕后分为两种情况,一个是返回用户态,一个是返回内核态。这个通过注释也能看出来。
  • 先来看看返回用户态: retint_user 会调用 prepare_exit_to_usermode,最终调用 exit_to_usermode_loop,和上面的逻辑一样,发现有标记则调用 schedule()。

内核态的抢占时机

对内核态的执行中,被抢占的时机一般发生在preempty_enbale()中

  • 在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调度preempt_disable()关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。

  • 从下面可以看出,preempt_enable()会调用 preempt_count_dec_and_test(),判断 preempt_count和TIF_NEED_RESCHED看是否可以被抢占。如果可以,就调用preempt_schedule->preempt_schedule_common->__schedule 进行调度

#define preempt_enable() \
do { \
	if (unlikely(preempt_count_dec_and_test())) \
		__preempt_schedule(); \
} while (0)
 
 
#define preempt_count_dec_and_test() \
	({ preempt_count_sub(1); should_resched(0); })
 
 
static __always_inline bool should_resched(int preempt_offset)
{
	return unlikely(preempt_count() == preempt_offset &&
			tif_need_resched());
}
 
 
#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)
 
 
static void __sched notrace preempt_schedule_common(void)
{
	do {
......
		__schedule(true);
......
	} while (need_resched())

内核态也会遇到中断的情况,当中断返回的时候,如果返回的仍然是内核态,这个时候也是一个被抢占的时机

  • 现在我们再来上面中断返回的代码中返回内核的那部分代码,调用的是preempt_schedule_irq。
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
......
	do {
		preempt_disable();
		local_irq_enable();
		__schedule(true);
		local_irq_disable();
		sched_preempt_enable_no_resched();
	} while (need_resched());
......
}
  • preempt_schedule_irq 调用 __schedule 进行调度

总结

整个进程的调度体系如下图

  • 第一条是调度核心函数 __schedule的执行过程
    • 进程的调度最终都会调用 __schedule函数
    • 在 __schedule里面会做两件事情:
      • 第一是选取下一个进程
      • 第二是进行上下文切换。上下文切换又分为用户态进程空间的切换和内核态的切换
  • 第二条是标记为可抢占的场景
  • 第三条是所有的抢占发生的时机
    在这里插入图片描述
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值