16 | 调度(中):主动调度是如何发生的?
下载APP
关闭
渠道合作
推荐作者
16 | 调度(中):主动调度是如何发生的?
2019-05-03 刘超 来自北京
《趣谈Linux操作系统》
课程介绍
讲述:刘超
时长20:16大小18.50M
上一节,我们为调度准备了这么多的数据结构,这一节我们来看调度是如何发生的。
所谓进程调度,其实就是一个人在做 A 项目,在某个时刻,换成做 B 项目去了。发生这种情况,主要有两种方式。
方式一:A 项目做着做着,发现里面有一条指令 sleep,也就是要休息一下,或者在等待某个 I/O 事件。那没办法了,就要主动让出 CPU,然后可以开始做 B 项目。
方式二:A 项目做着做着,旷日持久,实在受不了了。项目经理介入了,说这个项目 A 先停停,B 项目也要做一下,要不然 B 项目该投诉了。
主动调度
我们这一节先来看方式一,主动调度。
这个片段可以看作写入块设备的一个典型场景。写入需要一段时间,这段时间用不上 CPU,还不如主动让给其他进程。
另外一个例子是,从 Tap 网络设备等待一个读取。Tap 网络设备是虚拟机使用的网络设备。当没有数据到来的时候,它也需要等待,所以也会选择把 CPU 让给其他进程。
你应该知道,计算机主要处理计算、网络、存储三个方面。计算主要是 CPU 和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出 CPU,就像上面两段代码一样,选择调用 schedule() 函数。
接下来,我们就来看 schedule 函数的调用过程。
这段代码的主要逻辑是在 __schedule 函数中实现的。这个函数比较复杂,我们分几个部分来讲解。
首先,在当前的 CPU 上,我们取出任务队列 rq。
task_struct *prev 指向这个 CPU 的任务队列上面正在运行的那个进程 curr。为啥是 prev?因为一旦将来它被切换下来,那它就成了前任了。
接下来代码如下:
第二步,获取下一个任务,task_struct *next 指向下一个任务,这就是继任。
pick_next_task 的实现如下:
我们来看 again 这里,就是咱们上一节讲的依次调用调度类。但是这里有了一个优化,因为大部分进程是普通进程,所以大部分情况下会调用上面的逻辑,调用的就是 fair_sched_class.pick_next_task。
根据上一节对于 fair_sched_class 的定义,它调用的是 pick_next_task_fair,代码如下:
对于 CFS 调度类,取出相应的队列 cfs_rq,这就是我们上一节讲的那棵红黑树。
取出当前正在运行的任务 curr,如果依然是可运行的状态,也即处于进程就绪状态,则调用 update_curr 更新 vruntime。update_curr 咱们上一节就见过了,它会根据实际运行时间算出 vruntime 来。
接着,pick_next_entity 从红黑树里面,取最左边的一个节点。这个函数的实现我们上一节也讲过了。
task_of 得到下一个调度实体对应的 task_struct,如果发现继任和前任不一样,这就说明有一个更需要运行的进程了,就需要更新红黑树了。前面前任的 vruntime 更新过了,put_prev_entity 放回红黑树,会找到相应的位置,然后 set_next_entity 将继任者设为当前任务。
第三步,当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行。
进程上下文切换
上下文切换主要干两件事情,一是切换进程空间,也即虚拟内存;二是切换寄存器和 CPU 上下文。
我们先来看 context_switch 的实现。
这里首先是内存空间的切换,里面涉及内存管理的内容比较多。内存管理后面我们会有专门的章节来讲,这里你先知道有这么一回事就行了。
接下来,我们看 switch_to。它就是寄存器和栈的切换,它调用到了 __switch_to_asm。这是一段汇编代码,主要用于栈的切换。
对于 32 位操作系统来讲,切换的是栈顶指针 esp。
对于 64 位操作系统来讲,切换的是栈顶指针 rsp。
最终,都返回了 __switch_to 这个函数。这个函数对于 32 位和 64 位操作系统虽然有不同的实现,但里面做的事情是差不多的。所以我这里仅仅列出 64 位操作系统做的事情。
这里面有一个 Per CPU 的结构体 tss。这是个什么呢?
在 x86 体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程,x86 希望在内存里面维护一个 TSS(Task State Segment,任务状态段)结构。这里面有所有的寄存器。
另外,还有一个特殊的寄存器 TR(Task Register,任务寄存器),指向某个进程的 TSS。更改 TR 的值,将会触发硬件保存 CPU 所有寄存器的值到当前进程的 TSS 中,然后从新进程的 TSS 中读出所有寄存器值,加载到 CPU 对应的寄存器中。
下图就是 32 位的 TSS 结构。
图片来自 Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes
但是这样有个缺点。我们做进程切换的时候,没必要每个寄存器都切换,这样每个进程一个 TSS,就需要全量保存,全量切换,动作太大了。
于是,Linux 操作系统想了一个办法。还记得在系统初始化的时候,会调用 cpu_init 吗?这里面会给每一个 CPU 关联一个 TSS,然后将 TR 指向这个 TSS,然后在操作系统的运行过程中,TR 就不切换了,永远指向这个 TSS。TSS 用数据结构 tss_struct 表示,在 x86_hw_tss 中可以看到和上图相应的结构。
在 Linux 中,真的参与进程切换的寄存器很少,主要的就是栈顶寄存器。
于是,在 task_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 的执行顺序,不会因为编译阶段优化而改变,这里咱们可以忽略它)。
当进程 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 呢?其实我们从定义就可以看到。
在上面的例子中,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 里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。
课堂练习
你知道应该用什么命令查看进程的运行时间和上下文切换次数吗?
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
分享给需要的人,Ta购买本课程,你将得20元
生成海报并分享
赞 29
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
15 | 调度(上):如何制定项目管理流程?
下一篇
17 | 调度(下):抢占式调度是如何发生的?
精选留言(52)
- why2019-05-03- 调度, 切换运行进程, 有两种方式 - 进程调用 sleep 或等待 I/O, 主动让出 CPU - 进程运行一段时间, 被动让出 CPU - 主动让出 CPU 的方式, 调用 schedule(), schedule() 调用 __schedule() - __schedule() 取出 rq; 取出当前运行进程的 task_struct - 调用 pick_next_task 取下一个进程 - 依次调用调度类(优化: 大部分都是普通进程), 因此大多数情况调用 fair_sched_class.pick_next_task[_fair] - pick_next_task_fair 先取出 cfs_rq 队列, 取出当前运行进程调度实体, 更新 vruntime - pick_next_entity 取最左节点, 并得到 task_struct, 若与当前进程不一样, 则更新红黑树 cfs_rq - 进程上下文切换: 切换进程内存空间, 切换寄存器和 CPU 上下文(运行 context_switch) - context_switch() -> switch_to() -> __switch_to_asm(切换[内核]栈顶指针) -> __switch_to() - __switch_to() 取出 Per CPU 的 tss(任务状态段) 结构体 - > x86 提供以硬件方式切换进程的模式, 为每个进程在内存中维护一个 tss, tss 有所有寄存器, 同时 TR(任务寄存器)指向某个 tss, 更改 TR 会触发换出 tss(旧进程)和换入 tss(新进程), 但切换进程没必要换所有寄存器 - 因此 Linux 中每个 CPU 关联一个 tss, 同时 TR 不变, Linux 中参与进程切换主要是栈顶寄存器 - task_struct 的 thread 结构体保留切换时需要修改的寄存器, 切换时将新进程 thread 写入 CPU tss 中 - 具体各类指针保存位置和时刻 - 用户栈, 切换进程内存空间时切换 - 用户栈顶指针, 内核栈 pt_regs 中弹出 - 用户指令指针, 从内核栈 pt_regs 中弹出 - 内核栈, 由切换的 task_struct 中的 stack 指针指向 - 内核栈顶指针, __switch_to_asm 函数切换(保存在 thread 中) - 内核指令指针寄存器: 进程调度最终都会调用 __schedule, 因此在让出(从当前进程)和取得(从其他进程) CPU 时, 该指针都指向同一个代码位置.展开共 1 条评论72
- 安排2019-05-03proc文件系统里面可以看运行时间和切换次数,还可以看自愿切换和非自愿切换次数。 老师请教一个问题,A切到B, B切到C,C切到A,当最后切换回A的时候,A要知道自己是从C切换过来的,也就是last,这样做的目的是什么呢?A要对C做什么善后操作吗?
作者回复: 是的 finish_task_switch完成清理工作
26 - 憨人2019-05-17进程切换需要搞明白:我从哪里来,我要到哪里去
作者回复: 这句话赞
23 - coyang2019-05-03vmstat 、 pidstat 和 /proc/interrupts可以查看进程的上下文切换。21
- 刘強2019-05-05看了三遍,因为有一些基础,大概明白了。我觉得有个地方很巧妙。当函数返回的时候,由于切换了上下文,包括栈指针,所以一个进程函数执行return返回到了另一个进程,也就是完成了进程的切换。由此也可以看出,cpu也是比较"笨的",它只提供了基本的机制,至于如何利用这种机制,玩出花样,那就是各个操作系统自由发挥了。
作者回复: 是的,这一点比较绕
共 4 条评论15 - 尚墨2019-05-08刘老师,每个用户的进程都会被分配一个内核栈吗?
作者回复: 是的
共 2 条评论9 - kdb_reboot2019-07-27看起来ps 里面的TIME就是进程的 cpu runtime吧; 查看上下文切换,可以用cat /proc/x/status
作者回复: 赞
6 - kdb_reboot2019-07-27补充一下,看了最后的那张图,感觉切换,就是切内核态的 stack/rsp/pc, 这样下一个任务就能找到在哪执行了,以及继续怎么执行, 而内核态共享一片内存空间,所以不需要mm_switch,切换完了,返回用户态,用户态的stack/rsp/pc都被切换了, 而用户态的内存空间需要单独切换 老师,我理解的对吧?
作者回复: 是的
6 - 李圣悦2020-06-23一直以为内核简直就是一个很实在的艺术品,工作了几年,linux内核是我工作上半个师傅5
- 蹦哒2020-06-14“cpu_init会给每一个 CPU 关联一个 TSS,然后将 TR 指向这个 TSS,然后在操作系统的运行过程中,TR 就不切换了,永远指向这个 TSS” 看到这里,想到了一个设计模式:享元模式(Flyweight Design Pattern)
作者回复: 牛,这是从设计模式专栏过来的吧
5 - AlexS2020-10-15有两个小问题,望解答哦~~ 1. 貌似给出的__switch_to代码片段没有体现出把当前的cpu寄存器状态保存到prev的thread_struct里? 2. 再一个操作tss的用意是什么,首先它不能发触发TR的硬件动作吧?(TR的值变了才会有cpu和tss之间的换入换出); 再来,每个thread都有thread_struct存cpu寄存器状态了,为什么还需要tss?展开共 3 条评论4
- kdb_reboot2019-07-27关于指令指针的讲解,厉害了... 专栏有时候可以反者看, 先看最后总结,然后往上顺藤模块看你的分析 同时在读的书:lkd/ulk, 推荐给大家
作者回复: 先看总结也挺好的
3 - book尾汁2020-04-14还记得当年的场景“prev 是 A,next 是 B”,__switch_to_asm 里面 return prev 的时候,还没 return 的时候,prev 这个变量里面放的还是 C,因而它会把 C 放到返回结果中。但是,一旦 return,就会弹出 A 当时的内核栈。这个时候,prev 变量就变成了 A,next 变量就变成了 B。这就还原了当年的场景,好在返回值里面的 last 还是 C。 明白了返回结果有个单独的寄存器共 2 条评论2
- tupaopao2020-03-28TR指向tss_struct,并且不会改变的,但其实tss_struct记录了寄存器的修改,但是操作系统也保存了thread_struct thread 结构,主动的告诉硬件,我只需要保存修改这几个值,这样一来load的时候,也是操作系统干涉,从thread_struct中去修改tss_struct的某几个寄存器的值2
- 雨后的夜2019-12-25太精彩了!!!2
- 一笔一画2019-05-03老师,我还是对三个参数不解,A->B->C,如果再来一个D怎么办?
作者回复: 不影响,这里只站在a的角度看问题,从a到b,让后中间经历一万个进程,然后到c再到a,也是这个样子的
共 2 条评论2 - lzh2022-10-05 来自广东为什么switch_to()要3个参数? 看了这些链接: 1、http://www.wowotech.net/process_management/context-switch-arch.html 2、http://liujunming.top/2018/11/20/%E8%BF%9B%E7%A8%8B%E5%88%87%E6%8D%A2switch-to%E5%AE%8F%E7%AC%AC%E4%B8%89%E4%B8%AA%E5%8F%82%E6%95%B0%E5%88%86%E6%9E%90/ 3、https://www.spinics.net/lists/newbies/msg12904.html 我感觉目的就是为了在switch_to()返回后让进程A的prev接收last的值,而last就是该cpu调度进程A之前正在执行的进程C。只不过每次进程被调度时,从schedule()进入switch_to()都会把prev设置为自己,一方面供switch_to()第1个参数使用,表示“从何而来”,另一方面供switch_to()第3个参数使用,即用于switch_to()返回时我能知道cpu上一个执行的task是谁展开
- wsxzei2022-07-13补充以下对最后 switch_to 为什么使用三个参数的理解: 1、A 切换到 B 的时候,运行到 switch_to_asm 这一行的时候,是在 A 的内核栈上运行的,prev 是 A,next 是 B。 原因:在swich_to中仍然存在对 prev 和 next 变量的使用,因此 A 在调用`__switch_to_asm`函数前,会将这两个参数放入被调用者保存的寄存器(callee)中,当 __switch_to_asm 返回时会从内核栈中弹出这些值。因此从 进程A切换到进程B失去CPU执行权,到进程C切换回进程A,进程A从 __switch_to_asm 返回前,prev 被恢复为进程 A,next 恢复为进程 B。 2、`__switch_to_asm`的返回值指向进程 C。 原因: __switch_to_asm 的返回值存放在寄存器 %rax 中,注意到该函数会跳转到 __switch_to 执行,并且 __switch_to 返回时会执行 return prev_p; 。因此 rax 的值是由局部变量 prev_p 决定,进程 A 重新获得CPU资源是在进程C切换为进程A的情况下,因此 prev_p 为 进程 C。 总结:通过三个变量 switch_to(prev = A, next=B, last=C) ,A 进程当时被切换走的时候,是切换成 B,这次切换回来,是从 C 回来的。last指向进程 C,也即将prev指针从指向 A 变更为指向 C,next 指向进程 B。(注意switch_to是宏定义,而不是函数调用,因此会更改原先的prev指针)展开1
- 明天2021-04-07看了三遍 终于看懂了 A-》B-》C-》A 这整个过程才能让A最后知道我是被谁切走的 又从哪里切回来,其实last就是前一个内核栈的prev,只是需要将prev携带到next内核栈保留到last中1
- OOK2020-03-22哈哈哈,有开始变难了,主动调度和非抢占式调度没有关系哈1