前段时间看到了一个完成读比较高的协程库-libgo,里面提供了线程安全的协程实现,并且也是使用锁。本来我并没有给libcopp里的功能加锁的打算,因为上层dispatcher还是比较容易做到安全分发的,所以原来并不保证线程安全。而且线程安全这种问题单元测试比较难写,可能还得碰点运气。但是思来想去,还是为线程安全做点什么吧。反正也不是很复杂。
由于我并没有给utils加互斥锁的跨平台适配,所以先就直接用了自旋锁,来锁住需要考虑线程安全的地方。其实需要加锁的地方并不多,无非是管理器的增删查和task的next函数需要加锁。这些逻辑都很短,功能也很简单,并不会占用太多时间,所以自旋锁的问题也不大。而且以后真发现有问题,换掉也不是什么难事儿。
前段时间发现我的压力测试代码有问题。之前测出来使用内存池来做栈的创建开销和直接malloc或者mmap的性能差不多,所以一致没有做池子的功能。但是后来发现其实CPU耗在了缺页中断上面。于是尝试消除掉缺页中断的影响,然后发现创建性能提升了10倍。
这样的话,栈池就变得有意义了,而且由于我们新游戏是走得MMO路线,而这里异步操作比较多,也比较频繁,而且还有定时器恢复的时候也要重新进入协程。所以这个创建开销还是蛮重要的。原来一次协程创建大约需要8us,意味着QPS只有大约10W。现在缩减到300ns了,可以到300W,和一次协程切换差不太多,这样的话协程的开销就可以几乎忽略了,因为剩下的逻辑很难低于这个开销。另外栈池使用模板实现了,因为希望能够自定义池子里没有对象时的分配策略,并且能使用之前的栈分配器。所以模板的参数就是原来的分配器的类型。
boost 1.64发布了,所以顺便merge一下boost.context 1.64版本。但是这次merge的时候我看了下boost.context的汇编代码,让我对boost的代码质量开始表示怀疑了。
在merge boost.context 1.63之后,我这里libcopp的单元测试在MinGW下会崩溃。但是由于目前我这里没有在使用MinGW的环境作为开发所以并没有太在意。
然后这次的merge里我看到的CHANGELOG里有关于MinGW的修复,所以就去看了下他改了什么,结果发现其实是一个非常2B的错误(写错了一个寄存器名字)。
这种很容易发现的问题,竟然进入了Release里。这至少说明boost.context的单元测试覆盖本身就很有问题,或者说单元测试没过竟然就发布了。以后merge的时候还是得review一遍他的代码。
有一个重要的变化(虽然libcopp里并没有用到),废弃了execution_context。我之前就提到过,这个东西的设计很有诸多问题,功能和libcopp里的coroutine_container差不多,果然现在被废弃了。估计有使用这个的人或者其他库会挺伤的吧。
boost.context移除了coroutine_container,所以加了个一个更细粒度的API: callcc和continuation。这组API的实现的特点大致上如下:
无论是boost.context还是我的libcopp,都使用std::function作为回调委托。然而std::function底层如果绑定的是仿函数或者lambda表达式,就避免不了new操作。那么完全避免new的意义就不是很大。
所以libcopp仍然使用智能指针维护协程上下文。
一开始看到libgo的时候,发现它比libcopp快一些。但是因为底层都是boost.context,所以按理来说不会有太大差别。起初测试的时候用的是-O2,后来发现libcopp使用-O3编译的效果,性能就和libgo(因为libgo的CI里配置的是使用-O3)接近了。即便是这样,我后来还是发现libcopp能有一些优化空间。所以这次优化先是把自己构造shared_ptr改成了make_shared,来减少内存碎片。这个优化之后,libcopp的-O2也能有libgo的-O3相近的性能了。
最近还看到另一个库call_in_stack,作者说是创建栈的时候不要对齐到4KB,这样能减少分支预测的cache miss,能大幅提高协程效率,我这里拿最简单的切换测试了一下不对齐的栈空间的切换开销大约是20ns,即便在对齐的情况下也只有80ns。十分NB,不过这些测试都是没有栈和协程管理开销的情况下,裸的切换开销。我打算过段时间也去研究下这个。术语好像叫*stride access*,还有一个是辅助函数返回预测的,http://en.wikipedia.org/wiki/Branch_predictor#Prediction_of_function_returns 里有描述,等有空我也要研究下。而且他这个库并没有考虑线程安全、资源管理之类的东西,还需要额外的实现。而我们游戏中使用协程其实很容易cache miss的。因为逻辑必然比协程切换要复杂,几乎必然cache miss。所以我这里的压力测试结果和libgo差不太多,比它稍微好一些。
boost.context的新API,call/cc把context存在了分配的栈里,然后一64字节对齐加了offset,不清楚对这个缓存命中和分支预测是否有影响。所以顺便测试了一下。简要的说,测试结果是:-O2优化的情况下,创建boost::context::continuation的开销大约是330ns,协程切换开销大约是60-70ns。而如果只开启一个协程栈,切换开销只要14ns。这个性能大约是call_in_stack的一半,但是兼容性要好多了。 测试代码及测试结果见: https://gist.github.com/owt5008137/110a4f73dd8def89e98d4cd66a0b8260
接下来是libcopp profile结果: > 简报: *-O2情况下,使用栈池的创建开销约250ns。低cache miss协程切换约需要70ns,高cache miss需要约200ns,同时协程任务和action的维护代价约是40-50ns*。也就是说如果使用完整的协程任务+栈池。那么实际场景中的切换开销约250ns。简要的profile如下:
协程上下文切换
Samples: 169K of event 'cpu-clock', Event count (approx.): 42282500000
Children Self Command Shared Object Symbol
+ 68.13% 68.12% sample_benchmar sample_benchmark_coroutine_stack_pool [.] copp_jump_fcontext
+ 18.56% 0.00% sample_benchmar [unknown] [.] 0000000000000000
+ 18.48% 0.00% sample_benchmar sample_benchmark_coroutine_stack_pool [.] copp::detail::coroutine_context_container<copp::detail::coroutine_context_base, copp::allocator::stack_all
+ 18.48% 0.00% sample_benchmar [unknown] [k] 0xec83485355544155
+ 8.58% 8.58% sample_benchmar sample_benchmark_coroutine_stack_pool [.] copp::detail::coroutine_context_base::yield
+ 7.52% 7.52% sample_benchmar sample_benchmark_coroutine_stack_pool [.] copp::detail::coroutine_context_base::start
+ 5.32% 5.32% sample_benchmar sample_benchmark_coroutine_stack_pool [.] copp::detail::coroutine_context_base::jump_to
+ 4.64% 4.64% sample_benchmar sample_benchmark_coroutine_stack_pool [.] main
+ 3.55% 3.55% sample_benchmar sample_benchmark_coroutine_stack_pool [.] my_runner::operator()
+ 1.11% 1.11% sample_benchmar sample_benchmark_coroutine_stack_pool [.] copp::detail::coroutine_context_base::is_finished
协程任务切换
Samples: 211K of event 'cpu-clock', Event count (approx.): 52831500000
Children Self Command Shared Object Symbol
+ 51.24% 51.19% sample_benchmar sample_benchmark_task_stack_pool [.] copp_jump_fcontext
+ 15.25% 0.00% sample_benchmar [unknown] [.] 0xec83485355544155
+ 15.25% 0.00% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::task<my_macro_coroutine, my_macro_task>::~task
+ 8.14% 8.14% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::impl::task_impl::_cas_status
+ 7.82% 7.81% sample_benchmar sample_benchmark_task_stack_pool [.] copp::detail::coroutine_context_base::yield
+ 6.35% 6.34% sample_benchmar sample_benchmark_task_stack_pool [.] copp::detail::coroutine_context_base::jump_to
+ 6.14% 6.13% sample_benchmar sample_benchmark_task_stack_pool [.] copp::detail::coroutine_context_base::start
+ 4.53% 4.52% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::task<my_macro_coroutine, my_macro_task>::start
+ 4.02% 4.01% sample_benchmar sample_benchmark_task_stack_pool [.] main
+ 3.11% 3.06% sample_benchmar sample_benchmark_task_stack_pool [.] my_task_action
+ 1.61% 1.61% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::impl::task_impl::get_status
+ 1.40% 1.40% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::this_task::get_task
+ 1.29% 1.28% sample_benchmar sample_benchmark_task_stack_pool [.] copp::this_coroutine::get_coroutine
+ 0.98% 0.97% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::task<my_macro_coroutine, my_macro_task>::is_completed
+ 0.83% 0.83% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::task<my_macro_coroutine, my_macro_task>::yield
+ 0.77% 0.76% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::impl::task_impl::this_task
0.72% 0.72% sample_benchmar sample_benchmark_task_stack_pool [.] copp::detail::coroutine_context_base::is_finished
0.51% 0.51% sample_benchmar sample_benchmark_task_stack_pool [.] cotask::task<my_macro_coroutine, my_macro_task>::resume
更细节的压力测试可以见: Linux&maxOS 和 Windows 里的运行结果,反正每次构建都会跑。
顺便一提这次的优化也和libgo学了一招,不止让CI跑单元测试了,也去跑压测。这样随时能看到性能数据。另外之前说改成Release后性能大幅提高,但是我想应该不会在乎这点性能吧,所以改回了RelWithDebInfo,毕竟万一出问题了,可以看core dump或者调试还是蛮重要的。
不过我仍然保持一个观点,就是协程库只做好协程,所以并没有在里面集成一些系统调用的钩子,比如send、write等。这些应该通过附加组件的形式来做,并且又不难做,只是跨平台适配恶心点。所以我这里还是追求协程本身的功能和性能。
C++1z后面可能C++会内置支持co_await之类的关键字了。我最近也在抽空看它的原理和文档。后面有时间我也会做一下这些的集成和支持的。不过这个还没那么快。
还有就是前面提到的分支预测的优化,我也需要再找点资料。再评估一下,看看有没有必要搞进去。
我思考了一下,虽然当时做了很多软件工程上的预留(比如允许共享action之类)。但是实际使用过程中,这些预留很多都没有必要,未来会考虑移除这些预留,并且和boost.context的call/cc一样直接分配在栈上,这样能进一步减少内存碎片,不过这个优化会导致结构变化和部分API向前不兼容,所以也需要再设计一下。大致的思路就是尽可能把这些数据保存在栈里,不需要额外分配,这样就减少了内存碎片,也同时减少malloc的负担。
同时有必要进一步减少原子操作的频率,因为我这里测试的原子操作的每次写大约需要7ns,也就是大约是L1 cache的失效时间。现在有一些冗余可以进一步优化的。还有更多细节的地方可以优先使用右值引用来优化性能,这也能减少内部对原子操作的消耗。而且读写操作可以进一步优化同步屏障的类型,来减少cache miss的量。
按照boost.context的call/cc的profile的结果,在协程对象创建上能够优化的量已经比较小了,但是在切换上还有比较大的优化空间,现在在有些情况下libcopp的切换效率接近boost.context的call/cc,但是并不是很稳定有些情况还是会到200ns。当然因为要保证线程安全有些开销必不可少,所以的后续再深度分析一下。同时cotask的目前的创建开销和切换开销还比较大,还有比较可观的优化空间。不过这部分的对比因为和上面的一些优化点有关,所以我还是会优先解决上面提到的那些,再来看下效果。