一个 cpp 协程库的前世今生(二)协程切换的原理
再说协程框架之前,我们先了解一下协程切换的原理是什么。
下面的描述主要围绕 x86_64 体系架构、linux 操作系统进行。
阅读本节需要的基础知识
阅读本节需要了解以下基础知识:
各种寄存器的用途
栈的结构
call 指令对栈和寄存器的操作
函数调用的参数传递
栈帧的结构
gcc 内嵌汇编
gdb 调试器以及 objdump 工具的使用
线程上下文
操作系统以线程为单位进行调度,所以在线程切换的时候,操作系统需要保存什么东西?恢复什么东西呢?
一个可执行的线程是由数据和代码(或者称作指令)组成的,其中数据包含以下内容:
栈
堆
寄存器
指令其实就是 PC 寄存器指针。
我们依次说一下上面几部分内容如何保存。
栈
在程序运行的时候, 有一个特殊的寄存器 rsp 记录了运行时栈当前的位置。所以当我们保存了 rsp 的值就相当于保存了运行时栈的位置,而修改 rsp 的值就可以完成不同线程栈的切换。
所以,仅需要保存和恢复 rsp 就可以完成栈的保存和恢复了。
堆
堆内存是各线程共享的,不同的线程都是通过指针来访问对内存的,只要指针没有发生变化,就可以访问到堆内存。因此对内存是不需要保存与恢复的。
寄存器
寄存器是个重点,是当前 CPU 状态的直接体现。在所有的寄存器中,有一些寄存器肩负着特殊的使命。比如 rsp 保存了栈顶的位置,rip 保存了下一条指令的位置,rbp 通常保存当前栈帧的栈底(不绝对,编译器可能会将栈帧寄存器优化掉)。
因此我们需要将寄存器的状态保存在内存中,再切换回来的时候,从内存中恢复。
在线程中,发生切换时寄存器的信息会保存在 task_struct 结构中,对于我们协程来说,我们也需要一个这样的结构来保存和恢复寄存器信息。
所以归根结底其实就是要保存寄存器的信息。
哪些寄存器需要保存
在发生一个函数调用的时候,寄存器被分成两部分,一部分是调用者需要保存的寄存器。另一部分是被调用者需要保存得寄存器(之所以这么区分是为了性能)。假如我们调用一个函数 switch_to 进行切换,那么调用者需要保存的那一部分寄存器在调用之前就已经保存过了,我们不需要关心。我们需要关心的是被调用者保存的那一部分。这些寄存器主要有:rbp,rbx,r12,r13,r14,r15。
但是如果涉及到切换,我们要保存的计算器不止这么多,除了这些寄存器之外,我们还需要保存 rsp 和 rip(栈和代码段)。
一个切换的 demo
上面这个代码编译运行(使用无优化的-O0 参数进行编译,开启优化后的代码会出现一些问题,后面的章节会有介绍)后会交替输出co1 running
和co2 running
,接下来我来说明一下其原理。
初始化
和分析一个普通程序一样,我们都从 main 函数开始。
main 函数开始的时候,我们会对保存 co2 寄存器的那一块内存做一些初始化(因为 co1 是我们直接调用的,所以不需要初始化,在第 1 次切换的时候会由 switch_to 函数将 co1_regs 这一块内存写入正确的值)。
我们可以看一下顶部两个全局数组的注释。
两个数组分别会以上面注释的顺序保存 8 个寄存器的值。
那么在第 1 次运行的时候,哪些寄存器需要初始化呢?
rbp:当前栈帧的栈底
rsp:栈顶
rip:函数的入口地址
初次运行时,仅需要这三个寄存器的值。rip 肯定就是切换到的那个函数的入口地址,也就是 co2_routine,那么 rbp 和 rsp 的值应该怎样初始化呢?
首先我们想写两个协程,其实就是两个执行流,那么肯定不能使用同一个栈,因此我们需要为 co2 创建一个栈,此处实现比较简单,直接使用了全局变量 co2_stack。
注意这里一个细节,栈的增长方向是从高地指向低地址的,因此此处应该填写全局数组的末尾位置地址。
开始执行的时候栈是空的,所以 rbp 与 rsp 指向同一个位置。
切换
co2 的初始寄存器信息设置完成之后直接启动 co1,co1 运行在当前线程的上下文中。当 co1 运行到 switch_to 的时候,就会发生切换,进而切换到 co2 去执行。
下面分析一下 switch_to 函数。
switch_to 函数作用
switch_to 函数的作用是将当前的寄存器信息保存到第 1 个参数指向的内存中,并从第 2 个参数指向的内存中恢复寄存器信息。因此我们 co1 中第 1 个参数传入 co1_regs,第 2 个参数传入 co2_regs,就是相当于将当前的寄存器信息保存到 co1_regs,并从 co2_regs 恢复寄存器信息。
switch_to 函数共分为 2 个部分:保存、恢复,全是由汇编代码完成,上面的代码以空格做了分割,便于阅读。
x86_64 参数传递
x86_64 对于前六个整型参数使用寄存器传参,6 个参数依次为 rdi,rsi,rdx,rcx,r8,r9,因此汇编代码中的 rdi 就代表第 1 个参数 co1_regs, rsi 就代表第 2 个参数 co2_regs。
rip 的特殊处理
接下来我们分别将需要保存的寄存器拷贝到相对 rdi 指定的偏移处。此处有一点需要注意,就是我们在保存 rip 的时候,并不是直接操作 rip 寄存器的值,而是保存了8(%rsp)
位置的值,为什么要这么做呢?首先我们无法直接操作 rip 寄存器,无法直接向操作其他寄存器那样使用 mov 指令进行保存。其次,我们这里保存 rip 的目的是为了在切换回来的时候从哪里开始执行,对于一个鞋程来说,切换回来的时候,应该从他切出去的位置开始执行,也就是从调用 switch_to 函数的后面的一条语句开始执行。因此此处应该要保存的是 switch_to 函数的返回地址。
那么为什么是8(%rsp)
呢?在函数调用的时候,调用者会先将函数需要的参数写入参数寄存器(如果超过寄存器可以容纳的数量的话,需要使用栈传参),然后再将返回地址入栈,最后跳转的被调用函数的入口,函数返回的时候从栈顶弹出 rip,进行跳转。因此当被调用函数的第 1 条指令开始执行时。此时栈顶是返回地址。那么应该是(%rsp)
才对呀,为什么要偏移 8 个字节呢?
偏移 8 个字节是因为被调用函数在执行函数体之前,需要保存上一级的 rbp,并生成当前栈帧的 rbp:
因此想要获取到返回地址,还需要将这个 rbp 偏过去。
下面的恢复代码相同,不做赘述。
rsp 与 rip 的恢复顺序
最后需要注意的一点细节是,在保存上下文的时候,先保存哪个寄存器是无所谓的。但是恢复的时候,恢复的顺序是需要注意的。必须先恢复 rsp,才能恢复 rip,因为 rip 的恢复其实是要往 rsp 相对的位置写入数据的,如果 rsp 还没有恢复,那么就会写入切换前的栈帧中。
switch_to 的汇编代码
我们可以使用调试器或者是 objdump 工具来查看 switch_to 的汇编代码来验证上面的说法。
co1 与 co2 的交替执行
当在 co1 中执行到 switch_to 的时候,就会保存当前的寄存器信息到 co1_regs,然后切换到 co2 的入口函数开始执行。当 co2 执行到 switch_to 的时候,又会将当前的寄存器信息保存到 co2_regs,并切换到 co1 上次调用 switch_to 之后的那一条指令(switch_to 的返回地址)执行。因此就实现了 co1 和 co2 的交替执行。
总结
上下文保存与恢复是协程的核心,本文介绍的上下文切换的基本原理,并结合代码实例对切换过程中的寄存器操作做了比较详细的说明,希望读者可以实际动手进行实验,加深理解。
版权声明: 本文为 InfoQ 作者【SkyFire】的原创文章。
原文链接:【http://xie.infoq.cn/article/a9ba6f5fd4ac855de9f9be308】。文章转载请联系作者。
评论