Context Switch
前面我们了解了 linux 进程调度器的设计思路和注意框架
周期调度器 scheduler_tick 通过 linux 定时器周期性的被激活, 进行程序调度
进程主动放弃 CPU 或者发生阻塞时, 则会调用主调度器 schedule 进行程序调度
在分析的过程中, 我们提到了内核抢占和用户抢占的概念, 但是并没有详细讲, 因此我们在这里详细分析一下子
CPU 抢占分两种情况, 用户抢占, 内核抢占
其中内核抢占是在 Linux2.5.4 版本发布时加入, 同 SMP(Symmetrical Multi-Processing, 对称多处理器), 作为内核的可选配置。
1 前景回顾
1.1 Linux 的调度器组成
2 个调度器
可以用两种方法来激活调度
- 一种是直接的, 比如进程打算睡眠或出于其他原因放弃 CPU
- 另一种是通过周期性的机制, 以固定的频率运行, 不时的检测是否有必要
因此当前 linux 的调度程序由两个调度器组成:主调度器,周期性调度器(两者又统称为通用调度器(generic scheduler)或核心调度器(core scheduler))
并且每个调度器包括两个内容:调度框架(其实质就是两个函数框架)及调度器类
6 种调度策略
linux 内核目前实现了 6 中调度策略(即调度算法), 用于对不同类型的进程进行调度, 或者支持某些特殊的功能
- SCHED_NORMAL 和 SCHED_BATCH 调度普通的非实时进程
- SCHED_FIFO 和 SCHED_RR 和 SCHED_DEADLINE 则采用不同的调度策略调度实时进程
- SCHED_IDLE 则在系统空闲时调用 idle 进程.
5 个调度器类
而依据其调度策略的不同实现了 5 个调度器类, 一个调度器类可以用一种种或者多种调度策略调度某一类进程, 也可以用于特殊情况或者调度特殊功能的进程.
其所属进程的优先级顺序为
|
|
3 个调度实体
调度器不限于调度进程, 还可以调度更大的实体, 比如实现组调度.
这种一般性要求调度器不直接操作进程, 而是处理可调度实体, 因此需要一个通用的数据结构描述这个调度实体,即 seched_entity 结构, 其实际上就代表了一个调度对象,可以为一个进程,也可以为一个进程组.
linux 中针对当前可调度的实时和非实时进程, 定义了类型为 seched_entity 的 3 个调度实体
- sched_dl_entity 采用 EDF 算法调度的实时调度实体
- sched_rt_entity 采用 Roound-Robin 或者 FIFO 算法调度的实时调度实体
- sched_entity 采用 CFS 算法调度的普通非实时进程的调度实体
1.2 调度工作
周期性调度器通过调用各个调度器类的 task_tick 函数完成周期性调度工作
- 如果当前进程是完全公平队列中的进程, 则首先根据当前就绪队列中的进程数算出一个延迟时间间隔,大概每个进程分配 2ms 时间,然后按照该进程在队列中的总权重中占得比例,算出它该执行的时间 X,如果该进程执行物理时间超过了 X,则激发延迟调度;如果没有超过 X,但是红黑树就绪队列中下一个进程优先级更高,即 curr->vruntime-leftmost->vruntime > X,也将延迟调度
- 如果当前进程是实时调度类中的进程:则如果该进程是 SCHED_RR,则递减时间片[为 HZ/10],到期,插入到队列尾部,并激发延迟调度,如果是 SCHED_FIFO,则什么也不做,直到该进程执行完成
延迟调度**的真正调度过程在:schedule 中实现,会按照调度类顺序和优先级挑选出一个最高优先级的进程执行
而对于主调度器则直接关闭内核抢占后, 通过调用 schedule 来完成进程的调度
可见不管是周期性调度器还是主调度器, 内核中的许多地方, 如果要将 CPU 分配给与当前活动进程不同的另外一个进程(即抢占),都会直接或者调用调度函数, 包括 schedule 或者其子函数schedule, 其中 schedule 在关闭内核抢占后调用schedule 完成了抢占.
而__schedule 则执行了如下操作
__schedule 如何完成内核抢占
- 完成一些必要的检查, 并设置进程状态, 处理进程所在的就绪队列
- 调度全局的 pick_next_task 选择抢占的进程
- 如果当前 cpu 上所有的进程都是 cfs 调度的普通非实时进程, 则直接用 cfs 调度, 如果无程序可调度则调度 idle 进程
- 否则从优先级最高的调度器类 sched_class_highest(目前是 stop_sched_class)开始依次遍历所有调度器类的 pick_next_task 函数, 选择最优的那个进程执行
- context_switch 完成进程上下文切换
即进程的抢占或者切换工作是由 context_switch 完成的
那么我们今天就详细讲解一下 context_switch 完成进程上下文切换的原理
2 进程上下文
2.1 进程上下文的概念
操作系统管理很多进程的执行. 有些进程是来自各种程序、系统和应用程序的单独进程,而某些进程来自被分解为很多进程的应用或程序。当一个进程从内核中移出,另一个进程成为活动的, 这些进程之间便发生了上下文切换. 操作系统必须记录重启进程和启动新进程使之活动所需要的所有信息. 这些信息被称作上下文, 它描述了进程的现有状态, 进程上下文是可执行程序代码是进程的重要组成部分, 实际上是进程执行活动全过程的静态描述, 可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等
进程的上下文信息包括, 指向可执行文件的指针, 栈, 内存(数据段和堆), 进程状态, 优先级, 程序 I/O 的状态, 授予权限, 调度信息, 审计信息, 有关资源的信息(文件描述符和读/写指针), 关事件和信号的信息, 寄存器组(栈指针, 指令计数器)等等, 诸如此类.
处理器总处于以下三种状态之一
1. 内核态,运行于进程上下文,内核代表进程运行于内核空间;
2. 内核态,运行于中断上下文,内核代表硬件运行于内核空间;
3. 用户态,运行于用户空间。
用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递 很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的”进程上下文”
硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的 一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的”中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。
LINUX 完全注释中的一段话
当一个进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称 为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的 所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在 LINUX 中,当前进程上下文均保存在进程的任务数据结 构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程 的执行.
2.2 上下文切换
进程被抢占 CPU 时候, 操作系统保存其上下文信息, 同时将新的活动进程的上下文信息加载进来, 这个过程其实就是上下文切换, 而当一个被抢占的进程再次成为活动的, 它可以恢复自己的上下文继续从被抢占的位置开始执行. 参见维基百科-[context](https://en.wikipedia.org/wiki/Context_(computing), context switch
上下文切换(有时也称做进程切换或任务切换)是指 CPU 从一个进程或线程切换到另一个进程或线程
稍微详细描述一下,上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:
- 挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处,
- 在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复
- 跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程
因此上下文是指某一时间点 CPU 寄存器和程序计数器的内容, 广义上还包括内存中进程的虚拟地址映射信息.
上下文切换只能发生在内核态中, 上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。 Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少.
3 context_switch 进程上下文切换
linux 中进程调度时, 内核在选择新进程之后进行抢占时, 通过 context_switch 完成进程上下文切换.
注意 进程调度与抢占的区别
进程调度不一定发生抢占, 但是抢占时却一定发生了调度
在进程发生调度时, 只有当前内核发生当前进程因为主动或者被动需要放弃 CPU 时, 内核才会选择一个与当前活动进程不同的进程来抢占 CPU
context_switch 其实是一个分配器, 他会调用所需的特定体系结构的方法
-
调用 switch_mm(), 把虚拟内存从一个进程映射切换到新进程中
switch_mm 更换通过 task_struct->mm 描述的内存管理上下文, 该工作的细节取决于处理器, 主要包括加载页表, 刷出地址转换后备缓冲器(部分或者全部), 向内存管理单元(MMU)提供新的信息
-
调用 switch_to(),从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息
switch_to 切换处理器寄存器的呢内容和内核栈(虚拟地址空间的用户部分已经通过 switch_mm 变更, 其中也包括了用户状态下的栈, 因此 switch_to 不需要变更用户栈, 只需变更内核栈), 此段代码严重依赖于体系结构, 且代码通常都是用汇编语言编写.
context_switch 函数建立 next 进程的地址空间。进程描述符的 active_mm 字段指向进程所使用的内存描述符,而 mm 字段指向进程所拥有的内存描述符。对于一般的进程,这两个字段有相同的地址,但是,内核线程没有它自己的地址空间而且它的 mm 字段总是被设置为 NULL
context_switch( )函数保证:如果 next 是一个内核线程, 它使用 prev 所使用的地址空间
由于不同架构下地址映射的机制有所区别, 而寄存器等信息弊病也是依赖于架构的, 因此 switch_mm 和 switch_to 两个函数均是体系结构相关的
3.1 context_switch 完全注释
context_switch 定义在kernel/sched/core.c#L2711, 如下所示
|
|
3.2 prepare_arch_switch 切换前的准备工作
在进程切换之前, 首先执行调用每个体系结构都必须定义的 prepare_task_switch 挂钩, 这使得内核执行特定于体系结构的代码, 为切换做事先准备. 大多数支持的体系结构都不需要该选项
|
|
prepare_task_switch 函数定义在kernel/sched/core.c, line 2558, 如下所示
|
|
3.3 next 是内核线程时的处理
由于用户空间进程的寄存器内容在进入核心态时保存在内核栈中, 在上下文切换期间无需显式操作. 而因为每个进程首先都是从核心态开始执行(在调度期间控制权传递给新进程), 在返回用户空间时, 会使用内核栈上保存的值自动恢复寄存器数据.
另外需要注意, 内核线程没有自身的用户空间上下文, 其 task_struct->mm 为 NULL, 参见Linux 内核线程 kernel thread 详解–Linux 进程的管理与调度(十), 从当前进程”借来”的地址空间记录在 active_mm 中
|
|
qizhongenter_lazy_tlb 通知底层体系结构不需要切换虚拟地址空间的用户空间部分, 这种加速上下文切换的技术称之为惰性 TLB
3.6 switch_to 完成进程切换
3.6.1 switch_to 函数
最后用 switch_to 完成了进程的切换, 该函数切换了寄存器状态和栈, 新进程在该调用后开始执行, 而 switch_to 之后的代码只有在当前进程下一次被选择运行时才会执行
执行环境的切换是在 switch_to()中完成的, switch_to 完成最终的进程切换,它保存原进程的所有寄存器信息,恢复新进程的所有寄存器信息,并执行新的进程
该函数往往通过宏来实现, 其原型声明如下
|
|
| 体系结构 | switch_to 实现 |
|---|---|
| x86 | arch/x86/include/asm/switch_to.h 中两种实现定义 CONFIG_X86_32 宏 未定义 CONFIG_X86_32 宏 |
| arm | arch/arm/include/asm/switch_to.h, line 25 |
| 通用 | include/asm-generic/switch_to.h, line 25 |
内核在 switch_to 中执行如下操作
- 进程切换, 即 esp 的切换, 由于从 esp 可以找到进程的描述符
- 硬件上下文切换, 设置 ip 寄存器的值, 并 jmp 到__switch_to 函数
- 堆栈的切换, 即 ebp 的切换, ebp 是栈底指针, 它确定了当前用户空间属于哪个进程
__switch_to 函数
| 体系结构 | __switch_to 实现 |
|---|---|
| x86 | arch/x86/kernel/process_32.c, line 242 |
| x86_64 | arch/x86/kernel/process_64.c, line 277 |
| arm64 | arch/arm64/kernel/process.c, line 329 |
3.6.2 为什么 switch_to 需要 3 个参数
调度过程可能选择了一个新的进程, 而清理工作则是针对此前的活动进程, 请注意, 这不是发起上下文切换的那个进程, 而是系统中随机的某个其他进程, 内核必须想办法使得进程能够与 context_switch 例程通信, 这就可以通过 switch_to 宏实现. 因此 switch_to 函数通过 3 个参数提供 2 个变量.
在新进程被选中时, 底层的进程切换冽程必须将此前执行的进程提供给 context_switch, 由于控制流会回到陔函数的中间, 这无法用普通的函数返回值来做到, 因此提供了 3 个参数的宏
我们考虑这个样一个例子, 假定多个进程 A, B, C…在系统上运行, 在某个时间点, 内核决定从进程 A 切换到进程 B, 此时 prev = A, next = B, 即执行了 switch_to(A, B), 而后当被抢占的进程 A 再次被选择执行的时候, 系统可能进行了多次进程切换/抢占(至少会经历一次即再次从 B 到 A),假设 A 再次被选择执行时时当前活动进程是 C, 即此时 prev = C. next = A.
在每个 switch_to 被调用的时候, prev 和 next 指针位于各个进程的内核栈中, prev 指向了当前运行的进程, 而 next 指向了将要运行的下一个进程, 那么为了执行从 prev 到 next 的切换, switcth_to 使用前两个参数 prev 和 next 就够了.
在进程 A 被选中再次执行的时候, 会出现一个问题, 此时控制权即将回到 A, switch_to 函数返回, 内核开始执行 switch_to 之后的点, 此时内核栈准确的恢复到切换之前的状态, 即进程 A 上次被切换出去时的状态, prev = A, next = B. 此时, 内核无法知道实际上在进程 A 之前运行的是进程 C.
因此, 在新进程被选中执行时, 内核恢复到进程被切换出去的点继续执行, 此时内核只知道谁之前将新进程抢占了, 但是却不知道新进程再次执行是抢占了谁, 因此底层的进程切换机制必须将此前执行的进程(即新进程抢占的那个进程)提供给 context_switch. 由于控制流会回到函数的该中间, 因此无法通过普通函数的返回值来完成. 因此使用了一个 3 个参数, 但是逻辑效果是相同的, 仿佛是 switch_to 是带有两个参数的函数, 而且返回了一个指向此前运行的进程的指针.
switch_to(prev, next, last);
即
prev = last = switch_to(prev, next);
其中返回的 prev 值并不是做参数的 prev 值, 而是 prev 被再次调度的时候抢占掉的那个进程 last.
在上个例子中, 进程 A 提供给 switch_to 的参数是 prev = A, next = B, 然后控制权从 A 交给了 B, 但是恢复执行的时候是通过 prev = C, next = A 完成了再次调度, 而后内核恢复了进程 A 被切换之前的内核栈信息, 即 prev = A, next = B. 内核为了通知调度机制 A 抢占了 C 的处理器, 就通过 last 参数传递回来, prev = last = C.
内核实现该行为特性的方式依赖于底层的体系结构, 但内核显然可以通过考虑两个进程的内核栈来重建所需要的信息
3.6.3 switch_to 函数注释
switch_mm()进行用户空间的切换, 更确切地说, 是切换地址转换表(pgd), 由于 pgd 包括内核虚拟地址空间和用户虚拟地址空间地址映射, linux 内核把进程的整个虚拟地址空间分成两个部分, 一部分是内核虚拟地址空间, 另外一部分是内核虚拟地址空间, 各个进程的虚拟地址空间各不相同, 但是却共用了同样的内核地址空间, 这样在进程切换的时候, 就只需要切换虚拟地址空间的用户空间部分.
每个进程都有其自身的页目录表 pgd
进程本身尚未切换, 而存储管理机制的页目录指针 cr3 却已经切换了,这样不会造成问题吗?不会的,因为这个时候 CPU 在系统空间运行,而所有进程的页目录表中与系统空间对应的目录项都指向相同的页表,所以,不管切换到哪一个进程的页目录表都一样,受影响的只是用户空间,系统空间的映射则永远不变
我们下面来分析一下子, x86_32 位下的 switch_to 函数, 其定义在arch/x86/include/asm/switch_to.h, line 27
先对 flags 寄存器和 ebp 压入旧进程内核栈,并将确定旧进程恢复执行的下一跳地址,并将旧进程 ip,esp 保存到 task_struct->thread_info 中,这样旧进程保存完毕;然后用新进程的 thread_info->esp 恢复新进程的内核堆栈,用 thread->info 的 ip 恢复新进程地址执行。 关键点:内核寄存器[eflags、ebp 保存到内核栈;内核栈 esp 地址、ip 地址保存到 thread_info 中,task_struct 在生命期中始终是全局的,所以肯定能根据该结构恢复出其所有执行场景来]
|
|
3.7 barrier 路障同步
witch_to 完成了进程的切换, 新进程在该调用后开始执行, 而 switch_to 之后的代码只有在当前进程下一次被选择运行时才会执行.
|
|
而为了程序编译后指令的执行顺序不会因为编译器的优化而改变, 因此内核提供了路障同步 barrier 来保证程序的执行顺序.
barrier 往往通过编译器指令来实现, 内核中多处都实现了 barrier, 形式如下
|
|
关于内存屏障的详细信息, 可以参见 Linux 内核同步机制之(三):memory barrier
3.8 finish_task_switch 完成清理工作
finish_task_switch 完成一些清理工作, 使得能够正确的释放锁, 但我们不会详细讨论这些. 他会向各个体系结构提供了另一个挂钩上下切换过程的可能性, 当然这只在少数计算机上需要.
前面我们谅解 switch_to 函数的 3 个参数时, 讲到 注:A 进程切换到 B, A 被切换, 而当 A 再次被选择执行, C 再次切换到 A,此时 A 执行,但是系统为了告知调度器 A 再次执行前的进程是 C, 通过 switch_to 的 last 参数返回的 prev 指向 C,在 A 调度时候需要把调用 A 的进程的信息清除掉
由于从 C 切换到 A 时候, A 内核栈中保存的实际上是 A 切换出时的状态信息, 即 prev=A, next=B,但是在 A 执行时, 其位于 context_switch 上下文中, 该函数的 last 参数返回的 prev 应该是切换到 A 的进程 C, A 负责对 C 进程信息进行切换后处理,比如,如果切换到 A 后,A 发现 C 进程已经处于 TASK_DEAD 状态,则将释放 C 进程的 TASK_STRUCT 结构
函数定义在kernel/sched/core.c, line 2715中, 如下所示
|
|
-
No backlinks found.