协程没有秘密(二):几张图文快速入门协程,推荐收藏

大家好呀,我是码财同行。

在上一篇 协程没有秘密(一) 中,我们通过对程序执行机制的探讨,说明了协程产生的背景、并发情况下协程的调度过程。在文章结尾,我们还抛出了以下几个问题:

  • 协程上下文到底是啥?执行的代码切走指什么?
  • 调度:下一个待执行的协程怎么选?
  • 上下文处理:寄存器与栈的处理方式

不用着急,这篇文章我们就对这些问题一一解答。

上下文与协程切换

还记得吗?在上一篇文章中,我们提到,从微观上来说,程序执行就是CPU执行指令,读写内存中的数据的过程。

此时,如果 程序的执行流需要切换执行 ,例如从协程1切换到协程2,则 会通过一个称为上下文的结构来进行。上图就演化成下面这个样子:

可以看到,每个协程的上下文也都是存储在内存中的,切换的时候通过换入、换出操作来具体实施。

那来回答本文开头的第一个问题:协程上下文到底是啥?执行的代码切走指什么?

请往下看。

函数执行栈

我们知道,CPU 指令是由程序代码一条条翻译来的。来看一段相当简单的代码:

C++ 复制代码
int main()
{
    foo();
    bar();
}

void foo()
{
    // do something
    bar();
}
void bar()
{
    // do something
}

上面的代码的结构就是一个函数嵌套函数的过程,类似于套娃:

用计算机专业的术语,套娃也叫做递归,那么递归应该在哪里进行呢?可能很多人见过或者玩过下面这种汉诺塔的益智玩具:

玩的时候,肯定有底座和柱子,圆盘借助底座上的三根柱子来移动,最终完成所有圆盘迁移的任务。

类似的,计算机在做函数递归或者套娃的时候,也得有一个 "底座和柱子",它是单独开辟得一段内存空间,来存储函数执行过程中,每个函数自己的执行状态。我们把它叫做 函数执行栈 (call stack)。

从起始函数 main() 开始,随着程序执行流的顺序进行,依次将不同函数的执行状态(栈帧)压入或者弹出,栈里的数据状态变化过程如下:

上面这张图,演示的就是随着时间的推移,程序执行时候函数也在进进出出,栈里的数据也会不停的发生变化。

栈里的数据,如果遇到执行流执行到中间某一时间点,需要切换的情况(线程切换或协程切换),栈数据势必要作为程序上下文的一部分,能保存起来或恢复。

在这里,我还要强调一下内存的数据问题:内存是保存数据的,主要分为栈(函数执行栈)内存和堆内存(其他的内存空间这里不讨论),栈内存保存了当前CPU执行逻辑的函数执行栈帧;堆内存是程序动态分配的其他数据区。

指令执行上下文

让我们再回到 CPU 指令的执行上。一条 CPU 指令的执行,简单来说,是由以下几个过程组成:

  1. 取指令:CPU 从内存中读取一条指令,并将其存储在自己的寄存器中。
  2. 译码:CPU解析指令的操作码和操作数,确定指令要执行的操作类型以及需要使用的寄存器或内存位置。
  3. 执行指令:CPU 执行指令所指定的操作,可能涉及算术运算、逻辑运算、内存读写等。
  4. 写回:如果指令的结果需要存储到寄存器中,则 CPU 将结果写回到相应的寄存器。

在这里,寄存器 可以理解为 CPU 自带的更小更快的一块空间,可以快速访问。CPU 执行指令必须依赖这些寄存器。寄存器有几种类型:

  • EIP 寄存器:用来存储 CPU 要读取指令的地址
  • ESP 寄存器:指向内存数据中当前栈的栈顶位置
  • 其他通用寄存器的内容:包括函数调用时,传递函数参数的 rdi、rsi 等等。

寄存器里保存的值,如果遇到执行流切换的情况(线程切换或协程切换),同样的,也要作为程序上下文的一部分,随着执行流的切换,能保存起来或恢复。

特别的,需要提一下 EIP 寄存器,修改这个寄存器的值就相当于切换了执行流的指令,协程执行流切换就是依赖这个特性。

上下文切换

好了,通过上面的解释,到这里我们知道,程序的执行上下文包含下面两个:

  • 函数执行栈
  • CPU 执行的寄存器值

例如,Linux 平台 glibc 库中的上下文结构 ucontext 定义:

c++ 复制代码
typedef struct ucontext { 

   struct ucontext *uc_link; 

   sigset_t         uc_sigmask; 

   stack_t          uc_stack;    // 栈

   mcontext_t       uc_mcontext; // 寄存器

   ... 

} ucontext_t; 

执行流的切换,就是从上下文下手,如果把上下文完全改变,即:

  1. 改变 EIP 指令寄存器的值,指向下一个协程的指令地址;
  2. 将其他 CPU 寄存器值替换为下一个协程的寄存器值;
  3. 将函数执行栈指针替换为下一个协程的执行栈指针;

这样的话,当前线程运行的程序也就完全改变了,是一个全新的程序。

事实上,这件事情不需要手动去干,有很多系统调用或者库函数用一个函数就可以实现上下文的切换,如 Linux 平台的 swapcontext (当然,前提是你把 ucontext 结构中的值提前修改好):

c++ 复制代码
swapcontext(current_ctx, new_ctx);

现在,我们细化一下最开始的那张 CPU 读写内存的图,就可以将协程切换的过程表示如下:

当从当前协程(协程1)切换为下一个协程(协程2),我们要将当前上下文(寄存器 regs 数组和函数栈指针)保存到协程1对应的上下文中(寄存器 regs 数组和函数栈指针组成的上下文结构),再从内存中将协程2的上下文重新加载进来。

除了上面提到的 linux 平台的 ucontext(对应切换协程的swapcontext调用),要实现上下文切换,还有以下几个方法:

  • setjmp/longjmp
  • boost的context
  • 自己用汇编手动实现切换,来save/load寄存器的值,这种方式要给每种平台写一种,无法跨平台
  • 用 switch case 的跳转特殊技巧来实现(一般是无栈协程)

不同方法的移植性、性能、功能完整度都有区别,需要选择适合自己的方法。如果要说,以后可以再写一篇文章,这里不再赘述。

协作式调度

我们再来讨论一下协作式调度的问题。

协程的调度过程和线程的抢占式调度不一样,它是协作式的调度,当前协程必须要在适当时机主动切换出去,否则该协程就一直占用CPU,其他协程会得不到执行。

协程切换出去之后,由调度器再获取下一个可调度的协程,切换上下文执行,执行一段时间后,再在适当时机切换出去,依次反复。

具体的调度可以是:

调度器 -> 协程A -> 调度器 -> 协程B -> 调度器 -> ...

也可以是:

调度器 -> 协程A -> 协程B -> 协程C -> 协程D -> 调度器 -> ...

对于代码执行是否是多线程,又可以分为单线程调度与多线程调度。

如果是单线程调度,调度器和协程代码都在同一个线程执行,适用于业务是非CPU密集型的操作;

如果是多线程调度,则协程会在多个线程中执行,调度器也往往和协程不在同一个线程,对于CPU密集型业务比较友好,可以做到并行处理,但是业务上要考虑线程安全问题。

这里用文字的形式阐述,老实说,我自己都有点被绕进去了,所以如果有图的方式辅助理解协程调度,那就好了。例如下面 Golang 协程的调度:

这里强烈推荐大家看一下我之前写的 图解Go的协程调度,对于理解协程的调度非常有帮助。配合本系列文章食用,效果没得说。

好,这部分就回答了开篇第二个怎么选择下一个协程的问题。咱们继续。

协程栈的切换

咱们最后来讨论一下协程对栈的处理(这里指有栈协程),回答文章开头的第三个问题。

大家都知道多线程程序中各个线程的堆内存共享,而栈空间是独立的。

对于协程来说也一样,每个协程都必须有自己独立的栈空间才能保存执行时候的函数栈帧信息。而且随着函数调用的层级深入,实际使用的函数执行栈空间会一直增长,当需要的栈大小超过当初分配的内存大小时,就会造成栈溢出(部分平台有栈自动生长机制)。

如果给每个协程都分配一个比较大的固定大小的栈,则栈溢出的风险会大大降低,但是随之带来的是内存的消耗,如果系统中同时存在几万、几十万的协程,则内存占用极大。

为此,有人提出了 共享栈 的概念,即申请一块比较大的内存作为 "共享栈",每次调度一个协程来执行的时候,将其栈上的内容拷贝到这个 "共享栈" 上,执行完毕之后再计算实际栈的大小,将实际栈内容拷贝回协程原有的栈上。这样,协程的栈可以按照实际大小分配的较小,而共享栈可容纳任何协程来执行。

关于共享栈,云风有一个demo实现很好的做了示范:github.com/cloudwu/cor...,感兴趣的可以查看。

共享栈这种机制也带来了其他的问题:

  • 每次协程调用都要拷贝2次栈内存
  • 如果栈上有互相引用的指针,则拷贝后指针失效

其实,如果系统中并发不是特别高,同时存在的协程数量也不会特别巨大。且考虑到操作系统有虚拟内存机制,实际占用内存 RSS 一般不会太高。

也有一些项目用到了共享内存来做程序的热更新,则进程 resume 之后共享内存上的内容仍然存在,但是栈内存已不可用,此时必须放弃有栈协程,改用无栈协程。

小结

到这里,这篇文章的讲述就完了,我们来总结一下:

  1. 程序执行流(如协程)如果要切换,需要借助上下文的辅助。上下文包含函数执行栈、寄存器值等信息,可以有多种方法来实现上下文的换入、换出;

  2. 多个协程在并发的情况下,如果要做到被公平的执行,需要协作式调度。如何科学的选择下一个待执行的协程,以及协程执行一段时间后切换的时机选择,都是调度机制要解决的问题;

  3. 为了进一步优化协程执行消耗的内存资源,可以选择共享栈的方案,但是这也带来了使用上新的问题,需要权衡。

好了,看了这么多,一定很费脑力吧。来个笑话放松一下 :)

【笑话一则】这个月本来准备吃土的,但是万万没想到啊,突如其来的一场大雪改变了我的伙食。

感谢您花时间阅读这篇文章!如果觉得有趣或有收获,请来个关注、评论、点赞吧,您的鼓励是我持续创作的动力,蟹蟹!

| 往期推荐

基于本地知识库,定制一个私有GPT助手,不能再简单了

【建议收藏】服务注册与发现原理+踩坑,一文包教会

【技术·真相】谈一谈游戏AI - 真的搞懂寻路(一)

【技术·真相】谈一谈K8S的存储(一)

相关推荐
雯0609~17 分钟前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ21 分钟前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z26 分钟前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
XINGTECODE35 分钟前
海盗王集成网关和商城服务端功能golang版
开发语言·后端·golang
程序猿进阶41 分钟前
堆外内存泄露排查经历
java·jvm·后端·面试·性能优化·oom·内存泄露
FIN技术铺1 小时前
Spring Boot框架Starter组件整理
java·spring boot·后端
彭世瑜1 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4041 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish1 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five1 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript