资讯专栏INFORMATION COLUMN

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

boredream / 2646人阅读

摘要:限制协程应用的最大技术条件是上下文切换。既然本系列讲的是基于汇编的协程,那么这篇文章我们就来讲讲使用汇编来进行上下文切换的原理。切换上下文也称为保存现场和恢复现场。协程结束后,会返回到函数中。

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

协程调度

上下文切换

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

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

本文地址:https://segmentfault.com/a/1190000013177055

参考资料

基于 epoll 设计类似 libevent 的异步 I/O 库 - 接口

linux平台学x86汇编(十九):C语言中调用汇编函数

X64的函数调用规则

x86 和 x64 汇编调用C 函数参数传递规则(GCC)

从汇编角度浅析C程序

x86寄存器简介

协程分析之 context 上下文切换

Linux中的局部变量和栈

X86-64寄存器和栈帧

作为值的标签

用户态调度要保存些什么

上下文切换的具体内容

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

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

进程的创建和删除

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

当进程开始,操作系统要找到进程的入口,并且配置好上下文,然后将 CPU 交给进程

如果进程执行结束,则销毁进程资源,并正确返回到调用方(比如父进程)

进程调度时的上下文切换

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

夺取 CPU 使用权

保存当前用户进程的上下文

调用调度函数,找到下一个应当占用 CPU 时间片的进程

恢复下一个进程的上下文

将 CPU 交回给待继续的进程

示例代码

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

相关说明:

代码只支持 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"

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

首先 asm_amc_coroutine_dump() 将主线程的上下文保存在一个全局变量中

第二句将堆栈指针移动了一个单位,效果上就是忽略了在函数 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)


五句命令的含义分别是:

拷贝主线程的 rbx 寄存器值给协程——实际上这一句我不太懂,求高人指教。

重定向堆栈地址——这个堆栈,会在进入协程函数后才使用到。

重定向堆栈基址——同样地,进入协程函数后才使用到,所以这里不影响程序执行。

这就是前文提到的 func_ret_addr 成员,将这个地址压入堆栈,使得协程函数结束时即进入相应的函数中,这样我们就可以检测到一个协程已经执行完毕了。而由于协程是单线程运行的,因此我们可以使用全局变量判断出刚刚结束的是哪一个协程。

强制跳转到协程的入口处开始执行。

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

注意,这个函数实际上是不会返回的。返回到主线程的工作已经交给了被重定向了的 _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

除了标号之外的最前面的七行很好理解,就是将必要的现场保存起来。至于倒数第二、三行的 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 返回。这个时候在汇编中做了以下的事情:

从堆栈中取出函数的返回地址

调用 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)进行同步服务开发

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

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/10444.html

相关文章

  • 基于汇编 C/C++ 协程 - 背景知识

    摘要:近几年来,协程在服务器中的解决方案开始涌现。本文主要阐述以汇编实现上下文切换的协程方案,并且说明其在异步开发模式中的应用。协程原理协程的实现,涉及两个内容协程调度上下文切换协程的调度协程调度的原理,往大了说,其实和线程进程的调度原理无异。 近几年来,协程在 C/C++ 服务器中的解决方案开始涌现。本文主要阐述以汇编实现上下文切换的协程方案,并且说明其在异步开发模式中的应用。 本文地址:...

    VPointer 评论0 收藏0
  • Swoole协程之旅-前篇

    摘要:协程完全有用户态程序控制,所以也被成为用户态的线程。目前支持协程的语言有很多,例如等。协程之旅前篇结束,下一篇文章我们将深入分析原生协程部分的实现。 写在最前   Swoole协程经历了几个里程碑,我们需要在前进的道路上不断总结与回顾自己的发展历程,正所谓温故而知新,本系列文章将分为协程之旅前、中、后三篇。 前篇主要介绍协程的概念和Swoole几个版本协程实现的主要方案技术; 中篇主...

    terasum 评论0 收藏0
  • 协程原理】 - 协程不过是用户态线程

    摘要:也就是现代操作系统的虚拟内存空间。有在两个进程之间切换状态的时候,需要把内存的映射关系调整过来,否则虚拟内存的地址是无法对应到正确的物理地址的。但是原理是非常类似的。协程与线程的区别在于,协程的是在完全在用户态,由语言的或者是库来完成的。 TL;DR 笔者最美好的记忆来自于早年在6502 cpu的cc800上写汇编的年代, 那个时代的计算机甚至没有操作系统,也没有实模式等保护机制。在...

    pcChao 评论0 收藏0
  • Java 并发编程

    摘要:并发编程的核心是为了提高电脑资源的利用率,因为现代操作系统都是多核的,可以同时跑多个线程。合理配置线程池,密集型任务配置少数线程池如个数,密集型任务配置多一点的线程池如个数,其次是使用有界队列即使发现错误。 并发编程的核心是为了提高电脑资源的利用率,因为现代操作系统都是多核的,可以同时跑多个线程。那么是不是线程越多越好? 由于线程的切换涉及上下文的切换,所谓上下文就是线程运行时需要的资...

    nihao 评论0 收藏0
  • 并发编程导论

    摘要:并发编程导论是对于分布式计算并发编程系列的总结与归纳。并发编程导论随着硬件性能的迅猛发展与大数据时代的来临,并发编程日益成为编程中不可忽略的重要组成部分。并发编程复兴的主要驱动力来自于所谓的多核危机。 并发编程导论是对于分布式计算-并发编程 https://url.wx-coder.cn/Yagu8 系列的总结与归纳。欢迎关注公众号:某熊的技术之路。 showImg(https://...

    Jiavan 评论0 收藏0

发表评论

0条评论

boredream

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<