这里,可以看到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里面会做两件事情:
- 第一是选取下一个进程
- 第二是进行上下文切换。上下文切换又分为用户态进程空间的切换和内核态的切换
- 第二条是标记为可抢占的场景
- 第三条是所有的抢占发生的时机