主动调度:schedule();
典型场景:
- 写入块设备
- 网络设备等待一个读取
schedule 函数的调用过程:
- 在当前的 CPU 上,我们取出任务队列 rq。
- 第二步,获取下一个任务,task_struct *next 指向下一个任务,这就是继任。
- 第三步,当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行
通过三个变量 `switch_to(prev = A, next=B, last=C)`,A 进程就明白了,我当时被切换走的时候,是切换成 B,这次切换回来,是从 C 回来的。
进程上下文切换
一是切换进程空间,也即虚拟内存;二是切换寄存器和 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。
/** %rdi: prev task* %rsi: next task*/ENTRY(__switch_to_asm)....../* switch stack */movq %rsp, TASK_threadsp(%rdi)movq TASK_threadsp(%rsi), %rsp......jmp __switch_toEND(__switch_to_asm)
最终,都返回了 __switch_to 这个函数。这个函数对于 32 位和 64 位操作系统虽然有不同的实现,但里面做的事情是差不多的。所以我这里仅仅列出 64 位操作系统做的事情。
__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;}
在 x86 体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程,x86 希望在内存里面维护一个 TSS(Task State Segment,任务状态段)结构。这里面有所有的寄存器。
另外,还有一个特殊的寄存器 **TR(Task Register,任务寄存器**),指向某个进程的 TSS。更改 TR 的值,将会触发硬件保存 CPU 所有寄存器的值到当前进程的 TSS 中,然后从新进程的 TSS 中读出所有寄存器值,加载到 CPU 对应的寄存器中。
下图就是 32 位的 TSS 结构。
于是,Linux 操作系统想了一个办法。还记得在系统初始化的时候,会调用 cpu_init ,这里面会给每一个 CPU 关联一个 TSS,然后将 TR 指向这个 TSS,然后在操作系统的运行过程中,TR 就不切换了,永远指向这个 TSS。TSS 用数据结构 tss_struct 表示,在 x86_hw_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 来讲,这就算是完成了切换。**
例如 __switch_to 中的 load_sp0,就是将下一个进程的 thread_struct 的 sp0 的值加载到 tss_struct 里面去。
**
指令指针的保存与恢复
从进程 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 的最后三行指令(其中 barrier 语句是一个编译器指令,用于保证 switch_to 和 finish_task_switch 的执行顺序,不会因为编译阶段优化而改变,这里咱们可以忽略它)。
switch_to(prev, next, prev);barrier();return finish_task_switch(prev);
当进程 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 的调用了。返回到哪里呢?按照函数返回的原理,当然是从内核栈里面去找,是返回到 btrfs_wait_for_no_snapshoting_writes 吗?当然不是了,因为 btrfs_wait_for_no_snapshoting_writes 是在 A 进程的内核栈里面的,它早就被切换走了,应该从 B 进程的内核栈里面找。
假设,B 就是最前面例子里面调用 tap_do_read 读网卡的进程。它当年调用 __schedule 的时候,是从 tap_do_read 这个函数调用进去的。
当然,B 进程的内核栈里面放的是 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,恢复用户态的指令指针寄存器,从用户态接着运行。
到这里你是不是有点理解为什么 switch_to 有三个参数呢?为啥有两个 prev 呢?其实我们从定义就可以看到。
#define switch_to(prev, next, last) \do { \prepare_switch_to(prev, next); \\((last) = __switch_to_asm((prev), (next))); \} while (0)
在上面的例子中,A 切换到 B 的时候,运行到 switch_to_asm 这一行的时候,是在 A 的内核栈上运行的,prev 是 A,next 是 B。但是,A 执行完 switch_to_asm 之后就被切换走了,当 C 再次切换到 A 的时候,运行到 switch_to_asm,是从 C 的内核栈运行的。这个时候,prev 是 C,next 是 A,但是 switch_to_asm 里面切换成为了 A 当时的内核栈。
还记得当年的场景“prev 是 A,next 是 B”,__switch_to_asm 里面 return prev 的时候,还没 return 的时候,prev 这个变量里面放的还是 C,因而它会把 C 放到返回结果中。但是,一旦 return,就会弹出 A 当时的内核栈。这个时候,prev 变量就变成了 A,next 变量就变成了 B。这就还原了当年的场景,好在返回值里面的 last 还是 C。
通过三个变量 **switch_to(prev = A, next=B, last=C)**,A 进程就明白了,我当时被切换走的时候,是切换成 B,这次切换回来,是从 C 回来的。
总结
这一节我们讲主动调度的过程,也即一个运行中的进程主动调用 schedule 让出 CPU。在` schedule` 里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。
