协程2 --- 相关概念
文章目录
- 协程切换方案
- 协程库的完善程度
- 协程栈方案
- 协程调度实现
- 有栈协程与无栈协程
- 对称协程与非对称协程
协程切换方案
具体使用和解析看栈切换那个博客
-
使用
setjump
、longjump
c语言提供的方案
可参考:libmill
-
使用操作系统提供的api:
ucontext
、fiber
这种方式是最安全可靠的,但是性能比较差。
可参考:libtask
-
自己写汇编码实现
这种方式的性能可以很好,但是不同系统、甚至不同版本的linux都需要不同的汇编码,兼容性奇差无比。
可参考:libco
-
使用
boost
coroutine
、context
等性能很好,boost
也帮忙处理了各种平台架构的兼容性问题,不过需要依赖boost框架。
可参考:libgo
协程库的完善程度
-
API级
实现协程上下文切换api,或添加一些便于使用的封装。
如:boost.context
,boost.coroutine
问题:
没有协程调度
-
玩具级
实现了协程调度,无需用户手动处理协程上下文切换。
如:libmill问题:
没有HOOK
,只实现了一套网络io相关函数,这也意味着涉及网络的第三方库全部不可用了。 -
工业级
以部分正确的方式HOOK了网络io相关的syscall,可以少改甚至不改代码的兼容大多数第三方库。
如:libco问题:
没有完整生态
,协程间通讯、协程同步、调试等机制不够完善。未能完全模拟syscall的行为,只能兼容行为符合预想的同步模型的第三方库,只能覆盖一部分的第三方库。 -
框架级
以100%行为模拟的方式HOOK了网络io相关的syscall,可以完全不改代码兼容大多数第三方库。
如:libgo问题:由于C++的灵活性,
用户行为是不受限的
,所以依然存在几个边边角角的难点需要开发者注意:没有gc,TLS的问题,用户不按套路出牌、把逻辑代码run在协程之外,粗粒度的线程锁等等。 -
语言级
语言级的协程实现
如:golang开发者的一切行为都是
受限行为
,可以实现无死角的完善的协程。
c++20也支持协程了
协程栈方案
-
静态栈
栈大小固定,有大小难以权衡
的问题。
设置大了,会造成浪费。
设置小了,会有栈溢出问题。 -
分段栈
GCC支持一种允许栈内存不连续的编译参数,实现原理是在每个函数调用开头都插入一段栈内存检测的代码,如果栈内存不够用了就申请一块新的内存,作为栈内存的延续。
但是第三方库没有使用
这种方式来编译,那就无法在其中检测栈内存是否需要扩展,栈溢出的风险很大。 -
拷贝栈
每次检测到栈内存不够用时,申请一块更大的新内存,将现有的栈内存copy过去,就像std::vector那样扩展内存。
但C/C++是有指针的,栈内存的copy会导致指向其内存地址的指针失效
;又因为其指针的灵活性,修改对应的指针成为了一种几乎不可能实现的事情。 -
共享栈(libco)
申请一块大内存作为共享栈(比如8MB),每次协程挂起时计算协程栈真正使用的内存,copy到私有栈中;唤醒协程时,把协程私有栈的内存copy到共享栈中,这样每次只需保存真正使用到的栈内存量即可。
这种方案极大程度上避免了内存的浪费,做到了用多少占多少,同等内存条件下,可以启动的协程数量更多,但是协程切换慢
,还有引用失效
问题。 -
虚拟内存栈(libgo)
申请的内存并不会立即被映射成物理内存,而是仅管理于虚拟内存中,真正对其读写时才会触发缺页中断,分配物理内存
;而且基本上是按页递增分配。
协程调度实现
-
栈式调度(libco)
栈式调度是典型的不公平调度
。协程队列是一个栈式的结构,每次创建的协程都置于栈顶,并且会立即暂停当前协程并切换至子协程中运行,子协程运行结束(或其他原因导致切换出来)后,继续切换回来执行父协程;越是处于栈底部的协程(越早创建的协程),被调度到的机会就越少。 -
星切调度(libgo)
调度线程 -> 协程A -> 调度线程 -> 协程B -> 调度线程 -> …
调度线程居中,协程在周围,调度顺序图看起来就像是星星一样,称为星切。
将当前可调度的协程组织成先进先出的队列(runnable list),顺序pop出来做调度;新创建的协程排入队尾,调度一次后如果状态依然是可调度(runnable)的协程则排入队尾,调度一次后如果状态变为阻塞,那阻塞事件触发后也一样排入队尾,是为公平调度
。 -
环切调度
调度线程 -> 协程A -> 协程B -> 协程C -> 调度线程 -> …
调度线程居中,协程在周围,调度顺序图看起来呈环状,称为环切。
为了突破传统协程库仅用来处理I/O密集型业务的局限,也能适用于CPU密集型业务
,可充当并行编程库来使用。
有栈协程与无栈协程
所谓的有栈,无栈并不是说这个协程运行的时候有没有栈。
有栈协程是真的给你开了一个栈(如golang),主流的无栈协程方案(例如C++,Rust等),是把一个协程函数编译成状态机的逻辑,然后用一块临时分配的堆内存去保存这个函数里的变量和协程状态机以及上下文等内容。
无栈不管从效率,内存占用看当然是更优的方案,但是无栈需要编译器支持
,有栈只需要编写同一套上下文切换的代码。移植到有栈协程上比无栈也要相对简单,现在主流的无栈协程基本都需要进行侵入式的修改
。
使用上最大的区别就是协程是否可以在其任意嵌套函数中被挂起,有栈协程是可以的,而无栈协程则不可以。
有栈协程:
- 每个协程有单独的上下文,可以在任意的嵌套函数中任何地方挂起此协程
- 不需要编译器做语法支持,通过汇编指令即可实现
- 需要提前分配一定大小的堆内存保存每个协程上下文,所以会出现内存浪费或者栈溢出
- 上下文拷贝和切换成本高,性能低于无栈协程
无栈协程:
- 不需要为每个协程保存单独的上下文,内存占用低
- 切换成本低,性能更高
- 需要编译器提供语义支持
- 只能在这个生成器内挂起此协程,无法在嵌套函数中挂起此协程
- 异步代码必须都有对应的关键字
对称协程与非对称协程
- 对称协程 Symmetric Coroutine:任何一个协程都是相互独立且平等的,调度权可以在任意协程之间转移。
- 非对称协程 Asymmetric Coroutine:协程出让调度权的目标只能是它的调用者,即协程之间存在调用和被调用关系。
对称协程提供了更高的并发性和灵活性,适合需要高并发
处理的场景;而非对称协程则在某些控制流固定
的场景下更为适用。