C++协程库对比
太长不看版
-
无栈协程的性能比有栈协程强大约1~2个数量级
-
但是,用到协程的场合(比如后台服务),一般来说性能瓶颈是IO,协程的消耗可以忽略不计。这种情况下,更应该关注协程库的易用性和健壮性
-
很多有栈协程库并没有正确处理stack unwind,在使用这些库时必须保证协程函数体优雅退出,否则可能会引起资源泄露,严重时会导致crash
-
有栈协程库的共享栈并不是一个很好的方案,需要注意局部变量的使用,严重时会导致crash
-
直接看总结
1. 序
1.1. 简介
本文的主要内容是对C++几个协程库的基础协程功能进行易用性、健壮性和性能上的对比。这里的基础协程功能是指协程的创建、切换和销毁等,而不包括协程的调度、管理、同步或任何业务相关的功能。引用flare协程库文档里的说明:
是一种轻量的线程,也常被称为“纤程”、“绿色线程”等。其作为一个调度实体接收运行时的调度。 为方便使用,我们也提供了用于fiber的Mutex、ConditionVariable、this_fiber::、fiber局部存储等基础设施以供使用。
使用fiber编程时思想与使用pthread编程相同,均是使用传统的普通函数(这与下文中的coroutine形成对比)编写同步代码,并由运行时/操作系统负责在fiber/pthread阻塞时进行调度。
所以本文的讨论只涉及后者
本文研究的协程库如下:
-
【无栈】Boost.Asio无栈协程:通过3个宏实现了类似协程的语义,本质上是一个用Duff’s device实现的switch语句。它是一个只有300多行的头文件(注释占了200多行)
-
【无栈】C++20协程:C++20标准定义的协程,协程函数体的写法与有栈协程类似,编译器通过分析协程函数体,将协程状态和局部变量放到一块堆分配的内存上,从而转成无栈的形式。它具有极高的性能,还提供了很多concept可以自定义协程的行为,非常灵活
-
【无栈】cppcoro:目前C++20协程只定义了框架,相关的库函数极少,cppcoro正是这些缺失的库函数。cppcoro定义了task、generator、when_all等开箱即用的高级抽象
-
【有栈】Boost.Context:C++有栈协程库的中流砥柱,偏底层,支持ARM、MIPS、PowerPC、RISC-V、S390x、X86等平台,有着优秀的性能和稳定性。大量的有栈协程库只是对Boost.Context的封装
-
【有栈】libco:微信开发的协程库,共享栈和hook sys call是其两大特色。本文使用的是github开源版,据说微信内部使用的版本已完全不同于开源版本
-
【无栈+有栈】libcopp:从名字上可以看出作者的目标:libco的“pp版”。提供了多种协程的实现,本文涉及的是其中两个组件:
-
coroutine_context:基于Boost.Context的有栈协程,提供了更符合直觉的接口
-
future:参考rust语言协程模型设计的无栈协程,用于平滑接入C++20协程,直接使用会比较繁琐。由于篇幅限制,本文没有详细讨论,但加入到了benchmark里
-
其中使用的版本是:
版本 | 最后更新日期 | 备注 | |
---|---|---|---|
Boost | 1.77.0 | 2021/08/11 | |
cppcoro | master latest (a87e97fe5b6091ca9f6de4637736b8e0d8b109cf) | 2020/07/06 | |
libco | master latest (dc6aafcc5e643d3b454a58acdc78e223634bbd1e) | 2020/10/27 | |
libcopp | 1.4.1 | 2021/06/13 | 编译选项:-DLIBCOPP_DISABLE_ATOMIC_LOCK=ON |
本文没有讨论,但笔者有过了解的协程库有:
-
【无栈】Boost.Asio awaitable:Boost.Asio对C++20协程的封装,与Boost.Asio框架绑定,无缝配合。为方便起见,本文的echo_client示例使用了Boost.Asio awaitable
-
【无栈】libunifex:facebook主导开发的sender/receiver异步模型,是C++23 Executors提案的一个变种。协程只是里面的一个很小的部分,实现了sender/receiver模型与C++20协程的适配。cppcoro的作者参与了开发,可以理解为是cppcoro的强化版
-
【有栈】Boost.Coroutine2:基于Boost.Context的一个非对称协程的实现,现时来看没有太大特色
-
【有栈】Boost.Fiber:基于Boost.Context实现的协程调度、管理、同步等功能,属于上面所说的“纤程”库。实现了多种高级抽象,如:mutex、condition variable、barrier、channel、future、fiber local storage,还有NUMA架构优化和GPU计算
-
【有栈】flare:腾讯AMS广告营销服务线开发的公共组件,是腾讯TRPC里使用的协程库,协程方面的功能与Boost.Fiber类似,在性能和业务开发上有更多的思考。底层使用的也是Boost.Context
1.2. 示例说明:echo_server
我们将设计一个echo_server示例以展示各个协程库的简单使用方法,如下图所示:
当echo_server接收到的一个新的连接时,将会创建一个session进行管理以及收发数据。本文将用不同的协程库来实现session,其中session的接口如下所示:
|
|
2. 无栈协程
2.1. 基于switch语句的协程
|
|
PROS:
- 没有(没有库的依赖可能可以算一个优势)
CONS:
-
不能在协程函数体里定义跨越两次resume的局部变量
-
很原始,繁琐,非常难用
2.2. Boost.Asio无栈协程
|
|
resume()函数宏展开后:
|
|
PROS:
-
写法上有协程那味了,而且对比上一个方案,几乎没有性能开销
-
依赖只有一个头文件
CONS:
-
同样地,不能在协程函数体里定义跨越两次resume的局部变量
-
由于本质上是一堆switch和for语句,需要非常注意临时局部变量和break的使用
2.3. C++20协程
|
|
C++20协程是无栈协程,为什么buf可以放在协程函数体里呢?带着这个疑问,我们来查看协程函数体被编译后的汇编代码:
|
|
将汇编语言翻译过来,相当于:
|
|
如果将这个翻译后的代码再编译成汇编语言,相似度有95%
可以很清楚地看到,C++20协程被编译后,代码运行逻辑与switch语句类似,本质上仍是无栈协程
PROS:
-
用法上像有栈协程,却拥有无栈协程的性能,属于一种编译器帮你实现的高级语法糖
-
有很多concept可以自定义协程的行为
CONS:
-
需要C++20标准的支持
-
必须明确使用几个关键字来yield(这是为了让编译器做分析),无法做到业务无侵入
2.4. cppcoro
|
|
PROS:
-
所有C++20协程的优势,而且自带很多开箱即用的功能
-
task类已经自带了symmetric transfer的实现
CONS:
-
task类正常情况下无法手动resume,需要一些hack
-
作者的工作中心可能已经在libunifex上了,cppcoro已有一段时间没维护
3. 有栈协程
3.1. Boost.Context
Boost.Context有三种实现:fcontext_t、posix ucontext_t、WinFiber,通过编译选项来控制,它们的差别请参见这里。本文用的是性能最好的fcontext_t
fcontext_t主要针对各支持的平台,用汇编语言实现了以下三个接口:
-
make_fcontext:协程创建
-
jump_fcontext:协程上下文切换
-
ontop_fcontext:先进入协程的上下文,再调用一个自定义函数(后面还会讨论)
一般使用的是封装好的类boost::context::fiber
|
|
PROS:
-
支持多平台
-
优秀的性能和稳定性
CONS:
-
接口不太直观
-
需要大量用到移动语义(boost::context::fiber是move only的)
3.2. libcopp coroutine_context
libcopp coroutine_context底层使用了Boost.Context fcontext_t里的其中两个接口make_fcontext和jump_fcontext,并封装了一些人性化的接口
|
|
PROS:
-
符合直觉的接口
-
Boost.Context带来的优势 (?)
3.3. libco
|
|
PROS:
-
简单而直观的接口
-
支持共享栈 (?)
-
有hook sys call,可以将一些系统调用改成兼容协程的形式,这“可能”对某些用户很有用
-
微信的光环
3.4. stack unwind问题
运行echo_server,用echo_client向其建立1个连接发送任意数据随后关闭连接,查看echo_server的日志
用libcopp coroutine_context和libco实现的session的日志:
|
|
其他实现的session的日志:
|
|
也就是说,用libcopp coroutine_context和libco实现的session,buf的析构函数没有被调用
需要说明的是,本文实现的echo_server,当客户端关闭连接时,echo_server不会调用session的resume,而是直接delete session。相当于协程正在挂起状态时,强制被销毁了,而这正是问题的所在
在这种情况下,libcopp coroutine_context和libco没有做正确的stack unwind,虽然分配的栈被回收了,但是仍然活跃的局部变量的析构函数没有被调用
这在C语言里没有问题,因为C语言没有析构函数,如果你的程序是用C语言写的,那么可以放心使用他们
但在C++里是个大问题,有一个解决办法是:在销毁协程前,必须保证协程优雅退出,这样所有局部变量的析构函数都会得到正确的调用
但是更奇怪的一点是,同样是有栈协程的Boost.Context,为什么没问题呢?让我们来看一下boost::context::fiber的析构函数做了什么:
|
|
回顾一下ontop_fcontext的作用:先进入协程的上下文,再调用一个自定义函数
也就是说,它判断如果协程还没完结,则先进入这个被挂起的协程的上下文,然后抛了一个forced_unwind异常。我们无需关心这个异常怎么被处理,因为最关键的是,C++异常机制会保证正确的stack unwind,所以Boost.Context不会出现这个问题
libcopp coroutine_context只使用了Boost.Context的make_fcontext和jump_fcontext这两个接口,所以协程被强制销毁时没有正确的stack unwind
3.5. 共享栈问题
echo_server的逻辑非常简单,只是将接收到的数据原样返回,理论上echo_client发送和收到的数据是一样的,但我们还是加了一个检查
运行echo_client不间断地向echo_server发送数据,用libco共享栈实现的session会出现收发数据不一致、卡死或crash的情况。而其他实现的session则不会有问题
这是因为,start_async_recv和start_async_send的参数buf是协程栈上的一个引用。在共享栈的情况下,当协程被挂起时,该协程在共享栈中实际用到的栈内容,会被copy到一块内存中保存下来,准备让另一个协程切入,但buf仍然指向着这个共享栈的某个位置。当底层IO recv或send数据时,写入或读取的buf已经不是原协程创建的buf,数据错乱了
这是共享栈特有的问题,需要非常小心局部变量的使用,尽可能用堆分配的变量,或者将变量定义到session里
|
|
上面是libco源码里的一个片段,可以看到,代码逻辑针对共享栈做了hard core,防止引用局部变量的地址
实际上libgo这个协程库原本也是有共享栈的支持,但在2.4版本移除了它
4. Benchmark
测试条件:
CPU | Intel(R) Xeon(R) Gold 6133 CPU @ 2.50GHz (2 sockets, 40 cores, 80 threads) |
RAM | 256 GB |
有栈协程的栈大小 | 128 KB(这是很多协程库的默认值,实测中发现设置更小会很容易crash) |
4.1. 协程的创建、切换和销毁
测试项目说明:
-
create:协程创建耗时
-
switch:协程上下文切换耗时
-
last_switch:协程最后一次上下文切换耗时,有些协程库会在协程从函数体返回后做一些额外工作
-
destroy:协程从函数体返回后,销毁协程的耗时
-
kill:协程正在挂起状态时,强制销毁协程的耗时
运行1000个协程,每个协程switch 1000次,结果取单次的平均值(时间单位均是ns,纳秒)
create | switch | last_switch | destroy | kill | |
---|---|---|---|---|---|
【无栈协程】 | |||||
Boost.Asio无栈协程 | 1.13 | 2.18 | 2.88 | 0.02 | 0.03 |
C++20协程 | 22.58 | 2.35 | 2.87 | 13.51 | 12.57 |
cppcoro task | 22.70 | 2.73 | 2.99 | 15.57 | 16.38 |
cppcoro generator | 23.05 | 3.49 | 3.60 | 15.29 | 15.87 |
libcopp future | 4.10 | 3.77 | 11.31 | 2.57 | 8.23 |
【有栈协程】 | |||||
Boost.Context fiber | 1335.74 | 21.03 | 314.44 | 0.45 | 4525.73 |
Boost.Context callcc | 1253 | 20.93 | 330.72 | 0.42 | 5289.93 |
libco | 3998.96 | 55.98 | 55.68 | 885.63 | 895.96 |
libco共享栈 | 2545.37 | 89.33 | 123.53 | 583.04 | 583.77 |
libcopp coroutine_context | 2334.73 | 94.10 | 103.72 | 1175.87 | 1177.65 |
结论:
-
无栈协程的性能比有栈协程强大约1~2个数量级
-
Boost.Asio无栈协程的创建和销毁与普通对象的创建和销毁是一样的,设计良好的话可以避免动态内存分配。它的上下文切换就是一次函数调用
-
C++20协程对比Boost.Asio无栈协程主要差别是多了动态内存分配,正常情况下无法避免。它的上下文切换开销相当于一次函数调用
-
cppcoro的耗时与直接使用C++20协程差不多,但是cppcoro提供了更高的抽象
-
Boost.Context fiber与Boost.Context callcc耗时几乎一样,只是接口不太一样
-
Boost.Context会在last_switch时顺带销毁协程,所以为什么last_switch耗时这么长,而destroy耗时这么短
-
Boost.Context的destory和kill差异巨大,这是因为它被kill时会用C++异常机制来保证正确的stack unwind,而C++异常正是性能杀手。但其他有栈协程因为没有正确处理stack unwind,所以destroy与kill耗时差异不大
-
当libco开启了共享栈,create和destroy耗时都有降低(少了独立栈的内存分配和释放),但是switch耗时增加了,这是因为共享栈在上下文切换时,需要额外做栈的copy
4.2. Skynet 1M concurrency microbenchmark
该项目定义了一个测试并发性能的用例:先创建一个根协程(或者线程、goroutine等类似的并发单元),根协程创建10个子协程,子协程又创建10个孙协程,如此类推,直到有1000000个叶子协程,每个叶子协程都有一个编号(0~999999)。然后叶子协程将自己的编号返回给上一层,上一层协程对这些编号求和并返回给上上一层,上上一层如此类推,最终根协程得到的数值应该是499999500000
该项目包含了很多种编程语言的实现,本文的实现均为单线程,以下是测试结果:
耗时 | 备注 | |
---|---|---|
【无栈协程】 | ||
Boost.Asio无栈协程 | 5.54 ms | |
cppcoro task | 47.82 ms | |
cppcoro generator | 42.20 ms | |
libcopp future | 33.62 ms | |
【有栈协程】 | ||
Boost.Context fiber | 114.10 ms | |
Boost.Context callcc | 134.01 ms | |
libco | 3.44 s | |
libco共享栈 | 663.99 ms | |
libcopp coroutine_context | 2.98 s | |
【skynet项目里的其他实现】 | ||
go | 315.40 ms | go 1.15.5 |
go (GOMAXPROCS=1) | 935.04 ms | |
luajit | 317.55 ms | LuaJIT 2.0.4 |
python asyncio | 30.74 s | python 3.6.8 |
python gevent | 12.67 s | |
python tornado | 17.09 s |
5. 总结
易用性 | 健壮性 | 性能 | |
---|---|---|---|
【无栈协程】 | |||
Boost.Asio无栈协程 | C | A | S |
C++20协程 | B | A | A |
cppcoro | B+ | A | A |
libcopp future | D+ | A | A+ |
【有栈协程】 | |||
Boost.Context | A– | B+ | B- |
libco | A+ | C | C+ |
libco共享栈 | A+ | C | C |
libcopp coroutine_context | A | B- | C |
易用性说明:
-
等级:
-
A:有栈协程,可以实现业务无侵入
-
B:C++20协程,需要通过3个关键字co_await、co_return、co_yield进行控制
-
C:Boost.Asio无栈协程,需要通过3个宏reenter、yield、fork进行控制
-
D:基本就是手写switch语句
-
-
libco由于有co_hook_sys_call,可以将一些系统调用改成兼容协程的形式,这“可能”对某些用户很有用,额外加分
健壮性说明:
-
有栈协程因为有独立栈,天然存在栈溢出问题,所以健壮性都不如无栈协程
-
共享栈可以防止栈溢出,但共享栈存在栈对象引用问题,所以健壮性并没有额外加分
-
libco没有满足Sys V ABI规范的约束,额外减分,详情请看这里的分析。Boost.Context对每个支持的平台都正确实现了对应的调用约束规范,而使用Boost.Context汇编代码的libcopp也不存在这个问题