前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >协程及c++ 20原生协程研究报告 上

协程及c++ 20原生协程研究报告 上

作者头像
JohnYao
发布2022-06-29 15:12:44
5430
发布2022-06-29 15:12:44
举报
文章被收录于专栏:JohnYao的技术分享

引言

最近对C++20协程的进行了预研, 作为对比,同时研究了下市面上已经存在的其他协程实现方案。

虽然工作重点是C++20协程的预研,但作为一篇完整的文章, 不可避免的要从协程的基础开始讲起。

如果你已经对协程非常熟悉,尤其是知道栈(stack),帧(frame)在协程知识体系中意义,可以直接跳过相关章节。

一 协程概述

关于协程的定义和实现,并没有像进程和线程那样有统一的标准。而且由于都有一个“程”字,初学者很容易去和线程和进程做类比,往往走进理解的误区。

所以我们一开始只是引用维基的定义,简单说明下协程是什么:“协程是允许执行被挂起与被恢复子例程(也称为子程序 subroutine)”。

这一章节,我从函数切换的寄存器操作入手,继而通过协程的实现,和不同协程分类标准的介绍,帮助读者理解协程的本质。

二 函数切换 & 栈 & 帧

背景知识

我们都知道进程内存空间被划分为下面的各内存段(引自维基百科Code segment):

  1. text 代码段
  2. data 已经初始化的全局数据
  3. bss 未初始化的全局数据
  4. heap 堆区
  5. stack 栈区

对于函数切换以及后面介绍的协程切换,我们最关心的是栈区的内存的管理。

当函数调用发生后,会扩展栈区内存,用于存放函数体内声明的局部变量。当被调函数返回时,则释放这部分内存。每次函数调用扩展的这部分内存我们称之为栈帧。函数调用栈(call stack)就是由栈帧组成的,函数调用和返回就是栈帧(stack frame)入栈出栈的过程

我们以如下代码为例,看下这个过程中详细步骤。

代码语言:javascript
复制
#include <cstdint>
int64_t f(int p1, int p2, int p3, int p4, int p5, int p6, int p7, int p8)
{
  int x = 0;
  int y = 0;
  int64_t z = 0;
  int64_t l = 0;
  x++;
  p1++;
  p7++;
  p8++;
  return 0;
}

int main()
{
  int64_t i = 0;
  i++;
  i = f(1, 2, 3, 4, 5, 6, 7, 8);
  return 0;
}

这里需要一些汇编和基础知识,但由于这部分不是此篇文章的重点,感兴趣的同学可以自行学习。 对于本文要解释的函数切换过程,我们只需要了解如下内容:维持栈帧需要两个寄存器的支持,对于x86_64架构,即%rbp,%rsp。在本文中, 我们把他们称为

1. rbp 栈帧基地址寄存器: 当前栈帧的起始内存地址。

2. rsp 栈顶地址寄存器:整个调用栈的栈顶地址。

此外还需要了解:函数需要执行的下一条指令存放在%rip中,我们称之为指令地址寄存器。

有了如上的背景知识,我们看下在main函数中调用函数f,具体发生了什么。以此来加深栈帧的概念。

参数传递

根据上一节的内容,在函数f调用发生之前,%rbp指向main函数对应栈帧的基地址。%rsp指向整个栈区的顶地址。

函数调用的第一步是参数传递。上面例子参数传递部分,在gcc编译器下,产生的汇编如下:

代码语言:javascript
复制
        pushq   $8
        pushq   $7
        movl    $6, %r9d
        movl    $5, %r8d
        movl    $4, %ecx
        movl    $3, %edx
        movl    $2, %esi
        movl    $1, %edi

参数个数小于等于6时,采用寄存器传递,对于gcc使用的是%rdi, %rsi, %rdx, %rcx, %r8, %r9这6个寄存器。当大于6个参数时,需要使用栈区内存进行传递。本例中的参数7和8,都被执行了压栈操作pushq。 pushq,可以认为是两步操作

1. 扩展栈区地址空间,也就是将%rsp寄存器内容增加8 。

2. 将目的操作数,拷贝到这个新扩展的内存空间。

可以看到参数传递是在调用方完成的。

函数调用

函数调用产生的汇编代码如下:

代码语言:javascript
复制
call    f(int, int, int, int, int, int, int, int)

call指令也相当于三步操作

1. 扩展栈区地址空间,也就是将%rsp寄存器内容增加8(x86_64架构)。

2. 将%rip的内容(下一条指令,函数返回后需要执行的指令),拷贝到这个地址空间。

3. jmp到f函数标号。

被调函数的处理

代码语言:javascript
复制
  pushq   %rbp
  movq    %rsp, %rbp
  subq    $56, %rsp

被调用函数生成的汇编,需要执行三步初始化操作:

1. 保存调用者(main函数)的栈帧基地址寄存器的地址。

2. 将当前的栈顶指针,作为新的栈帧的基地址。

3. 根据当前栈帧(f函数)内申请的局部变量的总大小,直接扩展栈的大小。这里之所以用sub, 是因为栈的地址空间是从高地址向低地址部分扩展的。

切换总结

以上内容,可以总结为下图。

需要说明下,虽然为了画图方便,%rsp指向了一个未使用的地址,但实际上栈顶指针指向实际使用的地址: 比如参数传递部分,指向的是参数7的地址; 函数调用部分指向的是返回地址对应的地址。

在被调函数完成相关初始化处理后, 我们称绿色部分为函数f对应的栈帧也可以把 (%rbp %rip] 部分当做函数f的栈帧。虽然有些许差别,但在此处并不关键。

理解了上述函数调用的过程后,函数返回的过程可以简单的理解为以上操作的反向操作。

代码语言:javascript
复制
movl    $0, %eax
leave
ret

也是三步:

1. 函数返回值保存在%rax中

2. leave指令会把当前%rbp的值恢复到%rsp(恢复栈顶);会将保存在f函数栈帧中的main函数的栈帧基地址弹出, 恢复到%rbp寄存器中

3. ret指令会将保存在f函数栈帧中的返回地址(main函数函数调用后的下一条指令地址)弹出, 恢复到%rip寄存器中

三 有栈协程的实现

基于栈帧切换的协程

如果我们理解了上述函数调用的实现细节, 如果我们允许函数f 在执行某些等待异步操作的时机, 将它的执行上下文, 主要是各寄存器(通用寄存器, %rbp, %rsp, %rip等)的值, 保存在某个协程上下文对象(内存)中。

在异步操作完成,需要切换回来时, 我们再将这些保存的值恢复到各寄存器, 那么函数f 就成为了一个协程。当然这个过程还涉及到栈帧这块内存本身的处理, 这点在后面的小节马上介绍。

代码语言:javascript
复制
int64_t f(int p1, int p2, int p3, int p4, int p5, int p6, int p7, int p8)
{
  int x = 0;
  int y = 0;
  int64_t z = 0;
  int64_t l = 0;
  <等待异步操作-yield>
  x++;
  p1++;
  p7++;
  p8++;
  return 0;
}

posix ucontex, boost fcontext和tencent libco都是这种类型的协程。 对于有栈协程, 时刻要记住一点: 栈帧中使用的指针型变量, 如果不是指向该栈帧中的局部变量, 在协程恢复后其意义可能已经发生改变

有栈协程定义

有栈协程是指协程本身有自己独立的调用栈。基于栈帧切换的协程, 除了寄存器上下文,一般需要我们给协程指定其使用的栈空间。在协程切换后, 你会发现调用方的函数调用栈替换为了, 被调方协程的调用栈。 以libco为例, 协程的寄存器上下文保存在regs[ 14 ], 而协程使用的栈由ss_sp指定。

代码语言:javascript
复制
struct coctx_t
{
#if defined(__i386__)
    void *regs[ 8 ];
#else
    void *regs[ 14 ];
#endif
    size_t ss_size;
    char *ss_sp;
};

有栈协程切换

我们以libco为例看下有栈协程的切换。 libco的协程入口函数遵循如下原型:

代码语言:javascript
复制
typedef void* (*coctx_pfn_t)( void* s, void* s2 );

在函数切换前,我们要完成对上下文的初始化,主要是完成如下三点:

1. 将RSP(此时还不是寄存器,而是保存该寄存器的内存)设置为之前指定的ss_sp对应的地址空间的最大值-8(可以想下为什么设置为栈空间的最大值,前面已经提过)。

2. 将返回地址设置为协程函数pfn的起始地址,这样协程上下文切换后,就可以从指定的函数执行。

3. 将函数的参数保存在RDI, RSI(此时还不是寄存器,而是保存该寄存器的内存)

代码语言:javascript
复制
int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1) {
  char* sp = ctx->ss_sp + ctx->ss_size - sizeof(void*);
  sp = (char*)((unsigned long)sp & -16LL);

  memset(ctx->regs, 0, sizeof(ctx->regs));
  void** ret_addr = (void**)(sp);
  *ret_addr = (void*)pfn;

  ctx->regs[kRSP] = sp;

  ctx->regs[kRETAddr] = (char*)pfn;

  ctx->regs[kRDI] = (char*)s;
  ctx->regs[kRSI] = (char*)s1;
  return 0;
}

协程的切换过程相较于函数切换的call指令,使用的是coctx_swap函数。这是汇编实现的一个函数,函数原型如下:

代码语言:javascript
复制
extern void coctx_swap(coctx_t*, coctx_t*) asm("coctx_swap");

想下上面的“参数传递”小节,coctx_swap作为一个函数, 在进入该函数前。函数第一个参数coctx_t*会设置到%rdi,这个结构体用于保存切换前的协程上下文。第二个参数会设置到%rsi,这个结构体就是切换后的协程上下文。如果第二个参数对应的上下文结构刚通过上面coctx_make初始化完成。那么通过下述恢复操作,

代码语言:javascript
复制
    movq 48(%rsi), %rbp
    movq 104(%rsi), %rsp
    <other-code>
    movq 56(%rsi), %rdi
    <other-code>
    leaq 8(%rsp), %rsp
    pushq 72(%rsi)
    movq 64(%rsi), %rsi
    ret

关键的寄存器会被设置:

1. 设置新协程的栈帧基地址寄存器,此处是0。

2. 设置新协程的栈顶地址寄存器(前面已经介绍过,coctx_t结构中指定的ss_sp对应的地址空间的最大值-8)。

3. 通过"movq 56(%rsi), %rdi" 把coctx_make的第三个参数 void*s放置在参数传递寄存器%rdi。

4. 通过“leaq 8(%rsp), %rsp” 将栈顶降低8(用于保存下一步的返回地址)

5. 通过"pushq 72(%rsi)" 把coctx_swap返回后执行的指令进行压栈,也就是将协程函数pfn(coctx_make的第二个参数) 起始指令进行压栈。

6. 通过"movq 64(%rsi), %rsi" 把coctx_make的第四个参数 void* s1放置在参数传递寄存器%rsi。

7. 通过ret指令将第5步的压栈的地址弹出到%rip,开始了新协程函数的执行。

切换总结

在执行完被调函数初始化后,会开始新的栈的执行,后续该协程栈上的函数调用和普通函数调用没有区别。 完整的流程如下图:

再次说明下,虽然为了画图方便,%rsp指向了一个未使用的地址,但实际上栈顶指针指向实际使用的地址: 比如第二步中,指向返回地址对应的栈空间。

其他的有栈协程切换方式和libco类似,不一一赘述。

私有栈 vs 共享栈

libco在协程切换之上,还提供了私有栈和共享栈的封装。 私有栈是针对每个新开的协程都指定独立的栈空间,栈空间不能太大,有越界风险。 共享栈则是定义一个默认线程栈空间大小的栈,多个协程共享同一空间,使用者不用担心越界风险。但在协程切换时,涉及到栈空间的保存。

四 无栈协程

基于状态机的协程

这种方法, 本人还没有仔细研究。简单说下和其他同事讨论的相关结论: 这种方式并不会执行寄存器级的上下文保存和恢复, 只是将函数执行到的行号记录在协程对象的成员变量中, 协程函数通过switch case和跳转宏, 在恢复执行时跳转到指定的行号继续执行。 这种实现方式就可以认为是一种无栈协程。无栈并不是没有stack。而是在现有的stack上创建协程栈帧。不会为协程指定独立的stack空间。 C++20的原生协程就是此种实现。这里可以提前透露下,相较于其他无栈协程,C++20的原生协程创建的栈帧存在于堆上,我们可称之为堆帧,并不会随函数的挂起而销毁

五 对称协程 vs 非对称协程

关于协程还有一种分类方法,对称,非对称。对称协程只提供了一种协程间的控制转移的语义即pass control, 而非对称提供了两种, invoke和suspend。 利用libco可以实现对称协程,也可以实现非对称协程。但我们一般倾向于实现非对称协程,实现如下程序架构。

c++20的原生协程也是非对称式的。在协程挂起时会返回到它的调用方。但我们还是可以实现它的对称转移,其中原因下篇文章会讲到。 对称协程的控制转移示意图如下:

六 结语

本文对协程的做了基础的介绍,并从函数切换为起点对有栈协程的切换做了详细的分析。下一篇会继续介绍c20协程的预研成果,对比实验及库封装。

对C++20协程感兴趣的话,可以继续阅读本人的另一篇文章。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-12-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 一 协程概述
  • 二 函数切换 & 栈 & 帧
    • 背景知识
      • 参数传递
        • 函数调用
          • 被调函数的处理
            • 切换总结
            • 三 有栈协程的实现
              • 基于栈帧切换的协程
                • 有栈协程定义
                  • 有栈协程切换
                    • 切换总结
                      • 私有栈 vs 共享栈
                      • 四 无栈协程
                        • 基于状态机的协程
                        • 五 对称协程 vs 非对称协程
                        • 六 结语
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档