写点什么

一个 cpp 协程库的前世今生(二)协程切换的原理

作者:SkyFire
  • 2021 年 12 月 31 日
  • 本文字数:4109 字

    阅读完需:约 13 分钟

一个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

#include <chrono>#include <iostream>#include <thread>
// rbp rbx r12 r13 r14 r15 rip rspuint64_t co1_regs[8] = { 0 };uint64_t co2_regs[8] = { 0 };
uint64_t co2_stack[1024];
void switch_to(void*, void*){ __asm volatile("movq %rbp, (%rdi)"); __asm volatile("movq %rbx, 8(%rdi)"); __asm volatile("movq %r12, 16(%rdi)"); __asm volatile("movq %r13, 24(%rdi)"); __asm volatile("movq %r14, 32(%rdi)"); __asm volatile("movq %r15, 40(%rdi)"); __asm volatile("movq 8(%rsp), %rax"); __asm volatile("movq %rax, 48(%rdi)"); __asm volatile("movq %rsp, 56(%rdi)");
__asm volatile("movq 56(%rsi), %rsp"); __asm volatile("movq 48(%rsi), %rax"); __asm volatile("movq %rax, 8(%rsp)"); __asm volatile("movq 40(%rsi), %r15"); __asm volatile("movq 32(%rsi), %r14"); __asm volatile("movq 24(%rsi), %r13"); __asm volatile("movq 16(%rsi), %r12"); __asm volatile("movq 8(%rsi), %rbx"); __asm volatile("movq (%rsi), %rbp");}
void co1_routine(){ for (;;) { std::cout << "co1 running" << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(1000)); switch_to(co1_regs, co2_regs); }}
void co2_routine(){ for (;;) { std::cout << "co2 running" << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(1000)); switch_to(co2_regs, co1_regs); }}
int main(){ co2_regs[0] = (uint64_t)&co2_stack[1024]; co2_regs[6] = (uint64_t)&co2_routine; co2_regs[7] = co2_regs[0];
co1_routine();}
复制代码


上面这个代码编译运行(使用无优化的-O0 参数进行编译,开启优化后的代码会出现一些问题,后面的章节会有介绍)后会交替输出co1 runningco2 running,接下来我来说明一下其原理。

初始化

和分析一个普通程序一样,我们都从 main 函数开始。


main 函数开始的时候,我们会对保存 co2 寄存器的那一块内存做一些初始化(因为 co1 是我们直接调用的,所以不需要初始化,在第 1 次切换的时候会由 switch_to 函数将 co1_regs 这一块内存写入正确的值)。


我们可以看一下顶部两个全局数组的注释。


// rbp rbx r12 r13 r14 r15 rip rsp
复制代码


两个数组分别会以上面注释的顺序保存 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:


push %rbpmov  %rsp, %rbp
复制代码


因此想要获取到返回地址,还需要将这个 rbp 偏过去。


下面的恢复代码相同,不做赘述。

rsp 与 rip 的恢复顺序

最后需要注意的一点细节是,在保存上下文的时候,先保存哪个寄存器是无所谓的。但是恢复的时候,恢复的顺序是需要注意的。必须先恢复 rsp,才能恢复 rip,因为 rip 的恢复其实是要往 rsp 相对的位置写入数据的,如果 rsp 还没有恢复,那么就会写入切换前的栈帧中。

switch_to 的汇编代码

我们可以使用调试器或者是 objdump 工具来查看 switch_to 的汇编代码来验证上面的说法。


   0x0000000000001185 <+0>:     push   %rbp   0x0000000000001186 <+1>:     mov    %rsp,%rbp   0x0000000000001189 <+4>:     mov    %rdi,-0x8(%rbp)   0x000000000000118d <+8>:     mov    %rsi,-0x10(%rbp)   0x0000000000001191 <+12>:    mov    %rbp,(%rdi)   0x0000000000001194 <+15>:    mov    %rbx,0x8(%rdi)   0x0000000000001198 <+19>:    mov    %r12,0x10(%rdi)   0x000000000000119c <+23>:    mov    %r13,0x18(%rdi)   0x00000000000011a0 <+27>:    mov    %r14,0x20(%rdi)   0x00000000000011a4 <+31>:    mov    %r15,0x28(%rdi)   0x00000000000011a8 <+35>:    mov    0x8(%rsp),%rax   0x00000000000011ad <+40>:    mov    %rax,0x30(%rdi)   0x00000000000011b1 <+44>:    mov    %rsp,0x38(%rdi)   0x00000000000011b5 <+48>:    mov    0x38(%rsi),%rsp   0x00000000000011b9 <+52>:    mov    0x30(%rsi),%rax   0x00000000000011bd <+56>:    mov    %rax,0x8(%rsp)   0x00000000000011c2 <+61>:    mov    0x28(%rsi),%r15   0x00000000000011c6 <+65>:    mov    0x20(%rsi),%r14   0x00000000000011ca <+69>:    mov    0x18(%rsi),%r13   0x00000000000011ce <+73>:    mov    0x10(%rsi),%r12   0x00000000000011d2 <+77>:    mov    0x8(%rsi),%rbx   0x00000000000011d6 <+81>:    mov    (%rsi),%rbp   0x00000000000011d9 <+84>:    nop   0x00000000000011da <+85>:    pop    %rbp   0x00000000000011db <+86>:    ret    
复制代码

co1 与 co2 的交替执行

当在 co1 中执行到 switch_to 的时候,就会保存当前的寄存器信息到 co1_regs,然后切换到 co2 的入口函数开始执行。当 co2 执行到 switch_to 的时候,又会将当前的寄存器信息保存到 co2_regs,并切换到 co1 上次调用 switch_to 之后的那一条指令(switch_to 的返回地址)执行。因此就实现了 co1 和 co2 的交替执行。

总结

上下文保存与恢复是协程的核心,本文介绍的上下文切换的基本原理,并结合代码实例对切换过程中的寄存器操作做了比较详细的说明,希望读者可以实际动手进行实验,加深理解。

发布于: 2021 年 12 月 31 日
用户头像

SkyFire

关注

这个cpper很懒,什么都没留下 2018.10.13 加入

会一点点cpp的苦逼码农

评论

发布
暂无评论
一个cpp协程库的前世今生(二)协程切换的原理