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

2019-03-13 11:10:24 浏览数 (1)

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

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

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

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

本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

原文发布于:https://segmentfault.com/a/1190000013177055,也是作者本人的专栏。


参考资料

  • 基于 epoll 设计类似 libevent 的异步 I/O 库 - 接口
  • linux平台学x86汇编(十九):C语言中调用汇编函数
  • X64的函数调用规则
  • x86 和 x64 汇编调用C 函数参数传递规则(GCC)
  • 从汇编角度浅析C程序
  • x86寄存器简介
  • 协程分析之 context 上下文切换
  • Linux中的局部变量和栈
  • X86-64寄存器和栈帧
  • 作为值的标签
  • 用户态调度要保存些什么

上下文切换的具体内容

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

协程的切换要做的事情,和进程的切换,其实是差不多的。这里我们将本文涉及的要点提一下:

进程的创建和删除

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

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

进程调度时的上下文切换

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

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

示例代码

没有调查就没有发言权,没有实验也就没有讲解权。实际上本人已经有实现的代码了。后文就以我的代码(注意不是 master 分支)为脉络来说明。

相关说明:

  • 代码只支持 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() 函数。函数的基本逻辑如下:

保存主线程的现场

代码语言:txt复制
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(),直接进入协程。函数很简单:

代码语言:txt复制
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()。实际上这个函数在前面保存主线程现场中已经使用过了,这里我们再详细说明一下函数的实现:

代码语言:txt复制
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

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

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

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

代码语言:txt复制
g_pCurrentCoroutine->reg_rip = (uint64_t)(&&RETURN);

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

调度

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

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

这个过程就是下面两句:

代码语言:txt复制
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() 汇编函数,和切换协程的函数就是差在第一句汇编语句上:

代码语言:txt复制
popq %rsi

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


后记

个人觉得我关于协程的两篇文章恐怕看的人很少,或许现在用 C/C 写后台服务的人很少了吧,sad ……

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

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

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


本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

原文发布于:https://segmentfault.com/a/1190000013177055,也是作者本人的专栏。

0 人点赞