目录
分析__schedule()
第一步:
第二步:
pick_next_task的实现如下:
第三步:
进程上下文切换
内存空间的切换:
寄存器和栈的切换switch_to
指令指针的保存与恢复
总结
进程的调度分为两种方式,本篇文章先来看第一种主动调度。
有以下几个片段,第一个是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,就像上面两段代码一样,选择调用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函数中实现的。下面分几个部分来讲。
分析__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);//取出当前CPU的任务队列
prev = rq->curr;//指向的是正在运行的进程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 direc
*/
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,代码如下:
static 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,这就是充当CFS队列的那棵红黑树。
取出当前正在运行的任务curr,如果依然是可运行的状态,也即处于进程就绪状态,则调用 update_curr更新vruntime。update_curr就是根据实际运行时间算出 vruntime来。接着,pick_next_entity从红黑树里面,取最左边的一个节点。代码如下:
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);
对获取到的节点se,利用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。对于64位操作系统来讲,切换的是栈顶指针rsp。最终,都返回了__switch_to这个函数。
在64位操作系统中__switch_to这个函数做的事情分析如下:
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread;
struct thread_struct *next = &next_p->thread;
......
int cpu = smp_processor_id();
struct tss_struct *tss = &per_cpu(cpu_tss, cpu);
......
load_TLS(next, cpu);
......
this_cpu_write(current_task, next_p);
/* Reload esp0 and ss1. This changes current_thread_info(). */
load_sp0(tss, next);
......
return prev_p;
}
这里面有一个Per CPU的结构体tss。对于每个进程,x86希望在内存里面维护一个TSS(Task State Segment,任务状态段)结构。这里面有所有的寄存器。另外,还有一个特殊的寄存器TR(Task Register,任务寄存器),指向某个进程的TSS。更改TR的值,将会触发硬件保存CPU所有寄存器的值到当前进程的TSS中,然后从新进程的TSS中读出所有寄存器值,加载到CPU对应的寄存器中。
但是这样有个缺点。我们做进程切换的时候,没必要每个寄存器都切换,这样每个进程一个 TSS,就需要全量保存,全量切换,动作太大了。
于是,Linux操作系统想了一个办法。在系统初始化的时候会调用cpu_init,这里面会给每一个CPU关联一个TSS(X86中是每个进程维护一个TSS),然后将TR指向这个TSS,然后在操作系统的运行过程中,TR就不切换了,永远指向这个TSS。
void cpu_init(void)
{
int cpu = smp_processor_id();
struct task_struct *curr = current;
struct tss_struct *t = &per_cpu(cpu_tss, cpu);
......
load_sp0(t, thread);
set_tss_desc(cpu, t);
load_TR_desc();
......
}
struct tss_struct {
/*
* The hardware state:
*/
struct x86_hw_tss x86_tss;
unsigned long io_bitmap[IO_BITMAP_LONGS + 1];
}
在Linux中,真的参与进程切换的寄存器很少,主要的就是栈顶寄存器。在task_struct里面,还有一个我们原来没有注意的成员变量thread。这里面保留了要切换进程的时候需要修改的寄存器。
/* CPU-specific state of this task: */
struct thread_struct thread;
所谓的进程切换,就是将某个进程的thread_struct里面的寄存器的值,写入到CPU的TR指向的 tss_struct,对于CPU来讲,这就算是完成了切换。
指令指针的保存与恢复
从进程A切换到进程B,用户栈早就已经切换了,就在切换内存空间的时候。每个进程的用户栈都是独立的,都在内存空间里面。
那内核栈已经在__switch_to里面切换了,也就是将current_task指向当前的task_struct。 里面的void *stack指针,指向的就是当前的内核栈。
内核栈的栈顶指针在__switch_to_asm里面已经切换了栈顶指针,并且将栈顶指针在 __switch_to加载到了TSS里面。
用户栈的栈顶指针,如果当前在内核里面的话,它当然是在内核栈顶部的pt_regs结构里面呀。当从内核返回用户态运行的时候,pt_regs里面有所有当时在用户态的时候运行的上下文信息,就可以开始运行了。
唯一让人不容易理解的是指令指针寄存器,它应该指向下一条指令的,那它是如何切换的呢?
进程的调度都最终会调用到__schedule函数,称之为“进程调度第一定律” 。
我们用最前面的例子仔细分析这个过程。本来一个进程A在用户态是要写一个文件的,写文件的操作用户态没办法完成,就要通过系统调用到达内核态。在这个切换的过程中,用户态的指令指针寄存器是保存在pt_regs里面的,到了内核态,就开始沿着写文件的逻辑一步一步执行,结果发现需要等待,于是就调用__schedule函数。
这个时候,进程A在内核态的指令指针是指向__schedule了。这里请记住,A进程的内核栈会保存这个__schedule的调用,而且知道这是从btrfs_wait_for_no_snapshoting_writes这个函数里面进去的。
__schedule里面经过上面的层层调用,到达了context_switch的最后三行指令
当进程A在内核里面执行switch_to的时候,内核态的指令指针也是指向这一行的。但是在 switch_to里面,将寄存器和栈都切换到成了进程B的,唯一没有变的就是指令指针寄存器。当 switch_to返回的时候,指令指针寄存器指向了下一条语句finish_task_switch。
但这个时候的finish_task_switch已经不是进程A的finish_task_switch了,而是进程B的 finish_task_switch了。这样合理吗?你怎么知道进程B当时被切换下去的时候,执行到哪里了?恢复B进程执行的时候 一定在这里呢?这时候就要用到咱的“进程调度第一定律”了。当年B进程被别人切换走的时候,也是调用__schedule,也是调用到switch_to,被切换成为C进程的,所以,B进程当年的下一个指令也是finish_task_switch,这就说明指令指针指到这里是没有错的。
接下来,我们要从finish_task_switch完毕后,返回__schedule的调用了。 按照函数返回的原理,当然是从内核栈里面去找应该从B进程的内核栈里面找。假设,B就是最前面例子里面调用tap_do_read读网卡的进程。它当年调用__schedule的时候,是从tap_do_read这个函数调用进去的。于是,从__schedule返回之后,当然是接着tap_do_read运行,然后在内核运行完毕后,返回用户态。这个时候,B进程内核栈的pt_regs也保存了用户态的指令指针寄存器,就接着在用户态的下一条指令开始运行就可以了。
假设,我们只有一个CPU,从B切换到C,从C又切换到A。在C切换到A的时候,还是按照“进程调度第一定律”,C进程还是会调用__schedule到达switch_to,在里面切换成为A的内核栈,然后运行finish_task_switch。
这个时候运行的finish_task_switch,才是A进程的finish_task_switch。运行完毕从__schedule 返回的时候,从内核栈上才知道,当年是从btrfs_wait_for_no_snapshoting_writes调用进去的,因而应该返回btrfs_wait_for_no_snapshoting_writes继续执行,最后内核执行完毕返回用户态,同样恢复pt_regs,恢复用户态的指令指针寄存器,从用户态接着运行。
总结
主动调度的过程,也即一个运行中的进程主动调用__schedule让出CPU。在 __schedule里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。