2021-05-16

Eventloop及其他

最近一段时间写Envoy有些比较纠结的地方.

一个主要是eventloop模型,叠加C++本身的一些特性或者说问题.

写Java写Go的时候习惯于executor.submit或者go something.
毕竟closure带上上下文,交给调度器处理不在context的逻辑,一来心智上没什么负担随借随还.
二来毕竟多核,指不定并不实际影响latency,或者影响甚微.

而eventloop模型负担就比较重了.

因为本质上是一个topdown的设计,需要手工介入分时的概念,去衡量那些应该优先调度/调整顺序.

一个例子是写redis的proxy,外加一些协议层面的统计信息.
在实际debug情况下的测试结果,把统计关闭和打开大致会有几倍的性能差距.

原因也比较tuition.
相较于比较简单的协议解析来说,metric的各种lookup和string的allocation等反而开销更大.

所以一个直觉的处理方式就是异步化处理.

但是就eventloop模型来说,单线程其实没有所谓异步化处理.
最多也不过是对work queue的一些顺序调整.

当然,理论上来dealy到io事件之后处理是有可能利用一些本来就会花在poll上的时间的.
但也只是理论上.

实际上如果说真地在跑或者有了一定负载的话,大约也是io busy loop的.
所以delay到哪里其实代价都差不多.

另外一点就是envoy本身在libevent2上封装之后暴露出来的delay api要么是不停地塞到work queue的head,导致诸如delay([](){ doSomething(); delay(other)})这样的调用会直接递归出不来.

要么是timer base的delay方式粒度最小是1ms.
这多多少少不是让人很舒服.
因为是一个确定性的latency,而且不一定能容忍.

所以eventloop叠加callback或者coroutine之类的实现,本质上还是一个topdown的思维模式.
能做的最多就是次序上的调整,和基于此的一些speculative的开源节流方式.

而如果在这个思维模式下,加上C++的话又会有语言层面的一些纠结点.

既然已经在抠运行时调度的一些开销问题了,那么应对这些动作本身的开销也会多多少少考虑一些.

像比如要做delay的话,就必然涉及到把context带过去的问题.
直接如上的用lambda传递的话,在其他GC语言里可能就没那么多考虑.

毕竟即使考虑能做的也不多.
像Go可能还能做一些逃逸分析方面的考量,利用stack.
而Java类的基本就不用花这些心思了.

C++ lambda/std::function等就基本意味着要做value pass,触发copy.
而且一般来说就是memory allocation了.

当然理论上来说,可以写个stack allocator.
但对应的要做或者改造的地方就比较多了.

比如stack是哪个stack,如何保证这个stack是safe/accessible的.

一个简单的思路就是类似go的g0或者grouting stack.
用eventloop的frame层面的stack.
然后不够了fallabck会std allocator之类的.

但至少在envoy的场景这个是不太能做的.

毕竟寄宿在一层框架之上.

另外一个思路是可以在filter或者filter factory层面preallocate.
也就是在所属框架必然有的orignal/context object本身开辟一部分.

实际上也还是把自定义filter等组件作为一个goroutine stack来做allocation了而已.

这里带来的另外一个问题是这个allocation的lifecycle问题.

what if delay的operation执行的时候,这个hosted object free了?

不过在Envoy层面来说,这个问题不算大.

一来是从设计上来说,用了比较多的smart pointer,尤其比较严格的uniq_ptr.
object ownership关联关系方面的负担没那么重.

二是本身暴露的借口大多也有利用object自身的一个smart pointer做liveness check的.
在callback的时候会检查一下,所以也不用太担心.

但即使这样的话,也需要对lambda context bind的object做一些比较特殊或者类似的设计,以保证能够像envoy的这些security一样work.
而这样就意味着,如果没有follow这些practice的话,就容易或者说必然segment fault了.

关于这这点,一来是很难或者说不太容易做到这种API设计层面的约束.
二是即使做到了,接口结构层次也会显得不是太优雅.

总之,就是不太容易或者基本做不到在eventloop模型下,做一些优先级代价方面的取舍.

因为实际上无论如何都是要在一个线程内执行,只是先后的问题.
从线程自身的时间线来说,长度开销没有太大的区别.

那么跨eventloop调度呢?

Envoy的public API或者能使用到的API角度来说,是没有直接获取其他eventloop对象的.
在Envoy里叫做dispatcher.

但也只是不能直接.

因为它还提供了一个thread local相关的API借口.
是通过一个中心的main dispatcher向其管理 worker dispatcher post message切入到这些worker的context的实现.

也就是说能通过它间接地实现eventloop之间的非定向消息传递.
而既然又了非定向,结合一些比较ugly的诸如dispatcher name之类的标识ID之类的,也能实现定向message delivery.

只是做起来比较恶心.
而且还需要做一些额外的synchronize,保证在所有event loop确认触发了相应的事件/回调.

但这样一个明显的问题就是这种同步带来的不适感.
因为直觉上是对于各个eventloop的执行或者说性能不太友好的.

一个相对能够类比的例子就是GC的stop the world.

理论上体现到曲线上可能是会有一些毛刺感.

当然,就delay/async化metric统计来说,可以不用管同步问题.

一是不是特别重要.
二来,如果metric的值生成是instant/in place的,不同步处理最多也就是expose的方面有delay,影响不是很关键.

这样的话,另外一个点就是pass这些message的方式问题.

比如简单的一个std::list<Metric>,抛到另外一个eventloop的话,必然涉及到list的线程访问安全问题.

这个简单粗暴就是lock/mutex.
但考虑到像前面提到的,metric的生成速率本身比较高的话,mutex的同步开销就可能变得显著了.

一个相对优雅一些的做法是closure的binding方面做手脚.
把list move semantic,做clear cut,剥离竞争关系,一边是清空,一边是接管并顺带处理析构相关的开销.

这个算是一个观感尚佳的解决方案.

实际的问题是,这个异步eventloop可能处理不过来.
尤其前面场景,开启metric和关闭的性能是几倍到几十倍的差距.

这样的话,即使是1对1的eventloop配比关系有可能不够.

即使不考虑极端情况下worker eventloop已经把CPU跑满的情况,也需要考虑配比系数是怎样的.

而如果配比系数是adaptive的,那么随之而来的就是scale的算法和更重要的计算所需要用到的数据的synchronize或者不那么精确的approximate/estimate.
再就是进行scale时候的各个eventloop的同步以及已有callback/work queue如何/要不要re-balance的问题.

再极端一点的就是算法稳定性问题相关的,scale interval问题.
以及随之而来的re-scale的代价值不值等的问题.

最后下来可能就是重新发明了forkjoin pool/go primitive而已.

所以有时候会想,eventloop+manual memory management之于gc+runtime scheduler算是存在着代际的差异了.

至少从生产力层面来说.

而且如果说前者对于实现细节更有取舍控制权的话.
至少对于C++的实现来说,是需要有一些保留意见的.

像这里用到的例子,有比较让人不满意的差异的主要来源于debug build.
如果切换到release到话,可能差异就不是特别显著了.

这个imply的可能就是上古名言,不要过早优化所指向的真实含义.

编译器能做的优化可能比你想的更dirty/ugly.

像上面提到的lookup/string allocation等的开销.
再怎么写,可能不如丢给编译器inline加大范围的SSA/register allocation等.

就像后者的再怎么写,不如JIT的赌一把.

聊聊卡布里尼

最近看了部片叫卡布里尼,算是可能这段时间来比较有意思的一部电影. 故事也不算复杂,就是一个意大利修女去美国传教,建立慈善性质医院的故事. 某种程度上来说,也很一般的西方普世价值主旋律. 但是如果换一套叙事手法,比如共产国际的社会主义革命建立无产阶级广厦千万间的角度来看的话,也不是...