基于汇编的 C/C++ 协程 - 切换上下文

在前一篇文章《基于汇编的 C/C++ 协程 - 背景知识》中提到一个用于 C/C++ 的协程所须要实现的两大功能:html

  1. 协程调度
  2. 上下文切换

其中调度,其实在技术实现上与其余的线程、进程调度没有什么特别的差别,同时也要看具体业务的需求。限制 C/C++ 协程应用的最大技术条件是上下文切换。理由在前文也说了。linux

既然本系列讲的是基于汇编的 C/C++ 协程,那么这篇文章咱们就来说讲使用汇编来进行上下文切换的原理。git

本文地址:http://www.javashuo.com/article/p-pqrzzlzl-da.htmlgithub

参考资料

上下文切换的具体内容

首先咱们须要明白上下文切换具体须要作什么工做。我想,看这篇文章的读者应该对编译原理和操做系统基础知识已经有必定的基础了吧?小程序

协程的切换要作的事情,和进程的切换,实际上是差很少的。这里咱们将本文涉及的要点提一下:segmentfault

进程的建立和删除

当进程开始执行、以及进程执行结束的时候,操做系统还有别的工做:微信

  1. 当进程开始,操做系统要找到进程的入口,而且配置好上下文,而后将 CPU 交给进程
  2. 若是进程执行结束,则销毁进程资源,并正确返回到调用方(好比父进程)

进程调度时的上下文切换

当触发进程切换时(不管是进程调用阻塞的系统调用,可是操做系统主动触发 schedule),操做系统要作如下的几件事情:架构

  1. 夺取 CPU 使用权
  2. 保存当前用户进程的上下文
  3. 调用调度函数,找到下一个应当占用 CPU 时间片的进程
  4. 恢复下一个进程的上下文
  5. 将 CPU 交回给待继续的进程

示例代码

没有调查就没有发言权,没有实验也就没有讲解权。实际上本人已经有实现的代码了。后文就以个人代码为脉络来讲明。oracle

相关说明:异步

  • 代码只支持 x86_64 或 x64 架构。
  • 原来我打算继续开发下去,支持 i386 的;不事后来放弃了,由于我看到了已经用于大规模应用于微信的协程库 libco——这个我在之后的文章会讲。

协程的建立和执行

程序入口参见 main.cpp 文件的第 67 至 91 行,_true_main() 函数。

建立协程

建立协程使用的是 AMCCoroutineAdd() 函数,函数定义在这里。能够参照 struct _CoroutineInfo 结构体。

要执行协程,咱们须要为协程做如下准备:

分配栈空间

协程执行起来就像进程同样,须要有堆栈来实现函数调用。线程的堆栈是由操做系统分配的;协程因为工做在用户态,所以只能由咱们写代码分配了。

在个人代码中,栈空间使用 mmap() 分配。固然也可使用 malloc()——libco 就是这么作的。

栈空间的使用,是经过向栈寄存器直接赋值来实现的。这在后面再讲。

定位协程函数出入口

协程函数入口其实就是提供的协程函数自己,所以咱们只须要直接将函数的地址直接保存下来就好了。

可是协程出口就比较复杂了。协程执行到出口位置时(也就是协程函数的 return 语句)即表明协程结束。此时协程库应该可以正确捕捉而且记录下协程结束的状态,而且正确的切换到下一个应当被切换的堆栈。

被切换至的堆栈,多是另外一个协程,也有多是协程库的调用线程。

这一段代码我使用太重定向协程函数返回地址来实现的,须要搭配汇编使用。能够参见代码_coroutine_did_end() 函数。该函数在协程初始化的时候,保存在了 func_ret_addr 成员变量中。

请注意这个变量在结构体中的偏移值:64,下文的 asm_amc_coroutine_enter() 汇编函数就用上了。

CPU 寄存器保存区

当切换协程时,须要切换函数的上下文。切换上下文也称为 “保存现场” 和 “恢复现场”。所谓的 “现场”,其实就是必要的 CPU 寄存器值,这些寄存器里就已经包含了协程的堆栈。

参考资料用户态调度要保存些什么中就说明了在 GCC 程序中,须要保存的寄存器内容(x86_64 / x64):

  • rsp:栈指针,指向栈顶,也就是下一个可用的栈地址。
  • rbp:栈基址指针,与 rsp 配合使用。在不少小程序里面常常是 0,但咱们必须保存它。
  • rbx, r12 - r15:数据寄存器,也是必须保存的现场之一。
  • rip:程序运行的下一个指令地址。这是计算机执行程序的基础。

线程调用保存的环境更多,不过做为协程,咱们只须要保存上面这些寄存器就够了。


启动协程

启动线程的入口是 AMCCoroutineRun() 函数。函数的基本逻辑以下:

保存主线程的现场

asm_amc_coroutine_dump(g_pMainThreadInfo);  // dump main thread again to get return point of this function.
g_pMainThreadInfo->reg_rsp += 1 * sizeof(uint64_t);     // ignore return address for function "asm_amc_coroutine_dump"

协程要求单线程执行。本文所谓的主线程,指的就是启动协程的线程。这两句的逻辑以下:

  1. 首先 asm_amc_coroutine_dump() 将主线程的上下文保存在一个全局变量中
  2. 第二句将堆栈指针移动了一个单位,效果上就是忽略了在函数 asm_amc_coroutine_dump() 中保存的函数返回地址,使得全局变量中保存的是 AMCCoroutineRun() 的返回地址。

切换到待调用的协程上下文中

调用汇编函数 asm_amc_coroutine_enter(),直接进入协程。函数很简单:

asm_amc_coroutine_enter:
    movq (%rdi), %rbx
    movq 8(%rdi), %rsp
    movq 16(%rdi), %rbp
    push 64(%rdi)        # create a function return point
    jmp 56(%rdi)

五句命令的含义分别是:

  1. 拷贝主线程的 rbx 寄存器值给协程——实际上这一句我不太懂,求高人指教。
  2. 重定向堆栈地址——这个堆栈,会在进入协程函数后才使用到。
  3. 重定向堆栈基址——一样地,进入协程函数后才使用到,因此这里不影响程序执行。
  4. 这就是前文提到的 func_ret_addr 成员,将这个地址压入堆栈,使得协程函数结束时即进入相应的函数中,这样咱们就能够检测到一个协程已经执行完毕了。而因为协程是单线程运行的,所以咱们可使用全局变量判断出刚刚结束的是哪个协程。
  5. 强制跳转到协程的入口处开始执行。

前文不是说了一大堆须要保存的上下文吗,为何这里赋值的寄存器那么少?很简单,协程尚未开始执行呢,那些寄存器都不用恢复,让协程直接用就好了。

注意,这个函数其实是不会返回的。返回到主线程的工做已经交给了被重定向了的 _coroutine_did_end() 函数来完成。

协程的切换

获取 CPU 使用权

当切换协程时,调度函数须要获取 CPU 使用权,其实很简单:只是要求协程程序本身主动调用相关的函数,从而达到交出 CPU 使用权的目的。

参见 main.cpp 文件的第 33 至 62 行。这里定义了两个如出一辙的函数,至关于两个协程

做为 demo 程序,这里协程只调用了一个函数 AMCCoroutineSchedule() 提请切换协程。

保存协程现场

这里调用的是汇编函数 asm_amc_coroutine_dump()。实际上这个函数在前面保存主线程现场中已经使用过了,这里咱们再详细说明一下函数的实现:

asm_amc_coroutine_dump:
    movq %rbx, (%rdi)
    movq %rsp, 8(%rdi)
    movq %rbp, 16(%rdi)
    movq %r12, 24(%rdi)
    movq %r13, 32(%rdi)
    movq %r14, 40(%rdi)
    movq %r15, 48(%rdi)
    movq 16(%rsp), %rsi
    movq %rsi, 56(%rdi)
    retq

除了标号以外的最前面的七行很好理解,就是将必要的现场保存起来。至于倒数第2、三行的 movq 16(%rsp), %rsimovq %rsi, 56(%rdi) 就很回味无穷啦。

寄存器 rsi 在 GCC 中是做为第二参数使用的。这个函数中没有第二个参数,所以就只是做为临时变量而已。16(%rsp) 这一句,和前文中 “保存主线程的现场” 中的第二句代码的做用殊途同归。

另外,协程上下文的保存,还包含函数外面的一句 C 代码:

g_pCurrentCoroutine->reg_rip = (uint64_t)(&&RETURN);

这句话把被切换掉的协程恢复的现场重定向为 AMCCoroutineSchedule()return 语句。效果是跳过了下面的 asm_amc_coroutine_restore() 函数,避免重复调度。

调度

本 demo 中没有实质性的调度,只是轮询而已,找到协程链上的下一个协程并执行。

恢复下一个协程的上下文并交出 CPU

这个过程就是下面两句:

g_pCurrentCoroutine = g_pCurrentCoroutine->p_next;
asm_amc_coroutine_restore(g_pCurrentCoroutine);

只是简单的调用 asm_amc_coroutine_restore() 汇编函数的过程。这个汇编函数我就不贴上来了,由于其逻辑和前面的 asm_amc_coroutine_enter() 相同,只是保存的现场比较多而已。

协程的结束和销毁

前文说到,当协程结束的时候,会调用 return 返回。这个时候在汇编中作了如下的事情:

  1. 从堆栈中取出函数的返回地址
  2. 调用 retq 返回(retq 同时会将返回地址出栈丢掉)

这就是咱们前文中将协程返回地址重定向的原理基础。

协程结束后,会返回到 _coroutine_did_end() 函数中。这里须要注意的是,返回的位置是该函数的入口,所以反汇编会发现,这个函数还额外作了压栈的动做。不过不要紧,由于这个动做是在即将被销毁的协程堆栈中进行的,所以不用担忧内存泄露啥的。

这个函数作了如下几个操做:

将堆栈切换回主线程

调用汇编函数 asm_amc_coroutine_switch_sp_rip_to() 把当前的堆栈切换的主线程中。之因此要马上切换掉,是由于协程已经结束了,协程的资源也应该销毁。若是还在协程的堆栈上工做的话,那么堆栈销毁掉后会致使 segment fault。

销毁协程的堆栈和其余资源

这很好理解了,前面给协程分配了堆栈,用完了确定要还的。

其余协程调度

若是还有其余未完成的协程,那就调度过去,和前文同样。

返回到主线程

这里用的则是 asm_amc_coroutine_return_to_main() 汇编函数,和切换协程的函数就是差在第一句汇编语句上:

popq %rsi

这句话后面的注释也说了,其实仍是玩堆栈。这句话将这个汇编函数原来的返回地址出栈掉,采用以前重定向的地址——也就是主线程调用 AMCCoroutineRun() 以后的下一句代码

后记

我的以为我关于协程的两篇文章恐怕看的人不多,或许如今用 C/C++ 写后台服务的人不多了吧,sad ……

计划这系列文章是分三个部分的,分别是:

  • 协程介绍
  • 汇编原理
  • libevent 结合协程(libco)进行同步服务开发

前两部分就这样了,最后一部分,目前代码已经完成了,下一篇文章就是原理文档,欢迎阅读~

相关文章
相关标签/搜索