上下文切换
我们先来介绍下上下文切换具体指的是什么、有哪些实现上下文切换的方法,在下一节我们将进一步探讨下上下文切换的开销以及如何优化。
上下文,指的是什么呢?
一个任务的执行,本质上就是CPU执行其指令的过程,每执行一条指令,CPU内部寄存器状态都会发生一点改变,CPU内部寄存器的状态,其实就标识了任务当前的一个执行状态。这里记录任务状态的CPU寄存器信息就是我们常说的上下文。
上下文,如何表示呢?
现在,我们了解了上下文其实就是记录任务执行状态的寄存器信息。那表示一个上下文,需要用到CPU中的哪些寄存器呢?全部吗?还是一部分?
CPU中的寄存器数量跟具体CPU型号有关系,但是按照功能大致上可以划分为下面几类:
通用目的寄存器(AX\BX\CX\DX...);
段寄存器(CS\DS\SS...);
标识寄存器(EFLAGS);
指令(IR)及指令指针寄存器(IP);
其他寄存器,比如SP\GDTR\LDTR\CR3,等等。
更多信息可以参考[Intel手册](https://software.intel.com/content/www/us/en/develop/articles/intel-sdm.html)。
处理器中的寄存器很多,但是并不都是用来记录任务执行状态的,比如IR用于保存指令数据,跟任务执行状态没关系,但是CS:IP记录了下一条待执行的指令,跟任务执行状态有关系。
对于表示任务执行状态的寄存器,在执行上下文切换时,要妥善的将当前任务的这些寄存器的值保存下来,通常会保存到进程地址空间中,或者内核特定的数据结构中,以备下次被调度时还原上下文信息。比如还原CS:IP以继续从上次停下的位置执行,还原SP、BP以让进程继续应用以前的栈空间,等等。
对于进程切换而言,还需要还原CR3寄存器(由内核从特定数据结构中读取并加载到CR3寄存器),它代表了进程的页表起始地址。
需要说明的是,我们这里更聚焦的是进程内的一些上下文切换,而不是通俗意义上的进程、线程上下文切换,有相似之处,但是还是尽早说明下比较好,这样读者再看到下面的一些类型定义时就不至于困惑。或者,您也可以与内核 task_struct 结构体做个对比,会更有体会。
上下文,jmp_buf
我们看下Linux中如何标识进程上下文信息。熟悉Linux下c语言开发的同学,应该知道setjmp, longjmp这两个函数。setjmp将上下文信息记录到jmp_buf
,longjmp则可还原jmp_buf中信息到寄存器,实现“跳转”。longjmp可以实现函数间的跳转,这与goto不同,goto只能实现函数内的跳转。
这里的jmp_buf其实就是表示的栈上下文信息(stack context),以下是i386架构中 struct __jmp_buf
的定义,x86_64架构就先不赘述了,读者可自行查阅。
如果之前有写过汇编或者反汇编的经历,对这些寄存器应该不会太陌生。函数调用开头一般就是 "push epb", "mov ebp, esp", "sub $size, esp"的操作,函数返回前也常看到ret这种对eip进行修改的指令。
简单说,其实就是这些寄存器基本描述了一个任务的当前执行状态,当这个任务被切换出去的时候,需要保存这些寄存器信息到进程PCB(task_struct)中,等任务下次重新被调度的时候,再从进程PCB中读取并还原到寄存器,任务就可以不间断地继续执行下去。
现在,我们介绍了任务的上下文如何表示,也了解了上下文如何保存、恢复,我们还需要了解更多信息。
上下文,sigjmp_buf
前面提过了jmp_buf,jmp_buf有个不足之处,就是它没有考虑信号处理掩码的问题,glibc在此基础上做了改进,定义了一个新的类型sigjmp_buf
,对应的也提供了函数sigsetjmp/siglongjmp。
sigjmp_buf不仅保存了处理器硬件上下文信息(jmp_buf),还保存了信号掩码,这些共同构成了任务的上下文信息。
上下文,ucontext_t
在经历了jmp_buf、sigjmp_buf这样的改进之后,还是不能完全满足要求,我们还需要更加精细有力的上下文切换控制,ucontext_t诞生了。
在System-V系统中,ucontext.h提供了两个新类型和4个新函数。
上下文对应的类型mcontext_t是与机器相关的、不透明的,它作为ucontext_t的一个内部成员。ucontext_t定义则至少包含了如下字段:
上下文相关的4个操作函数:
wikipedia提供了一个非常赞的示例,来演示如何实现任务切换,如下所示。初接触的读者开始会有点懵,可以先试着阅读下代码,猜测下可能的结果。然后我再来解释下这里的逻辑。
下面的示例程序中创建了3个ucontext_t变量,main_context1在68行被设置,loop函数退出时loop函数绑定的loop_context将被销毁,程序中会隐式调用setcontext(loop_context.uc_link)来切换到父上下文中去执行,即getcontext(&main_context1)对应的下一条指令地址处(70行)。
main函数执行时,什么时候激活loop函数呢,在while循环内swapcontext的时候(80行),执行到这里的时候,main函数将当前上下文信息存储到main_context2,并切换到loop_context去执行,执行什么呢?50行~61行定义了loop_context的父上下文,以及loop_context绑定的函数执行时要采用的栈空间,当然还绑定了要执行的函数loop以及传递给loop的参数信息。loop函数将使用数组iterator_stack[SIGSTKSZ]作为自己的栈空间。
loop执行起来之后,会进入一个循环,每轮循环会更新一下变量i_from_iterator的值,然后呢,就执行上下文切换swapcontext(loop_context, other_context),这里的other_context刚好是main_context2,切过去之后就是执行语句printf("%d\n", i_from_iterator)来完成变量的打印。然后进入while(1)循环,此时又会执行上下文切换继续执行loop函数,loop函数从上次停下来的地方继续执行,这样连续打印了0~9。
然后,loop函数for循环结束,退出函数,loop_context结束,此时函数退出前会隐式调用setcontext以切换到父上下文main_context1去执行,由于前面main函数执行时已经将变量iterator_finished更新为了1,所以此时即便从main_context1继续执行,也不会再次进入while循环再次执行loop函数。
最后,程序正常结束。
我们可以通过 gcc -o main main.c
编译并且通过 ./main
来运行,运行会得到如下结果:
这里的volatile qualifier是为了避免编译器相关的优化,在这个示例中作用不大,如果是多线程+强一致CPU中还有点用。关于volatile的细节可以参考我的另一篇文章:[你不认识的cc++ volatile](https://www.hitzhangjie.pro/blog/2019-01-07-%E4%BD%A0%E4%B8%8D%E8%AE%A4%E8%AF%86%E7%9A%84cc++-volatile/)。
总结一下,ucontext_t提供了更灵活的上下文切换控制,是比jmp_buf、sigjmp_buf更好的选择。ucontext_t的设计初衷,也是为了支持实现一些更加高级的流程控制、fiber、coroutine等。所以协程并不是什么新概念,它只是一种更细粒度的、更轻量的调度实体。
如果只实现协程其实也没什么大用,还需要大量的其他基础设施,如协程之间如何通信,如何做到同步、互斥,等等,这些才是决定好用不好用的问题。所以我们也能理解为什么协程库有五花八门的实现,但是没有哪个很出名的,但是go却能独领风骚,大致就是这个原因吧。
言归正传,我们是否可以直接利用ucontext_t来实现协程呢?我们还需要考虑下上下文切换的开销才能决定。ucontext_t的目的是通用,因此其内部mcontext_t包含了所有的CPU寄存器信息,有些是没必要的,如果每次上下文切换都把寄存器信息保存一下、恢复一下,这里的开销我们人类感觉不到,但是机器是能感觉到的,必须要考虑下,这部分内容我们在下一节介绍。
当前比较火的libgo协程库,之前也是基于ucontext_t来实现的,不过后面的版本做了些优化,去掉了一些冗余的寄存器的保存、恢复操作,可以参考libgo patches setcontext/getcontext。
Google's Ian Lance Taylor explained in the commit improving libgo, "Currently, goroutine switches are implemented with libc getcontext/setcontext functions, which saves/restores the machine register states and also the signal context. This does more than what we need, and performs an expensive syscall. This CL implements a simplified version of getcontext/setcontext, in assembly, that only saves/restores the necessary part, i.e. the callee-save registers, and the PC, SP. A simplified version of makecontext, written in C, is also added. Currently this is only implemented on Linux/AMD64."
参考资料
setcontext, https://en.wikipedia.org/wiki/Setcontext
libgo cheaper context switch on x86_64, https://gcc.gnu.org/legacy-ml/gcc-patches/2019-05/msg02155.html
linux man(2)
Last updated