2021-07-10

关于Go的一些相对优势

最近用Go重写了之前基于Envoy的一个东西.

性能从原来的32 CPU 20w+ 18ms latency变成40 CPU 40w+ 10ms.

这个结果初看没什么可比性.
但是拆分起来还是可以谈一谈的.

Envoy的版本大概就是到32 CPU的时候就上不去了.
也就是说之后的性能并不随着CPU资源的增加而scale.

这个之前略微谈过.

大致模型就是:
Time = sys_cost + read_fd*(read_bytes*cost_per_read_byte) + write_fd*(write_bytes*cost_per_write_byte).
sys_cost是固有的eventloop的开销.

后两项是抽象的读写每字节对应的动态抽象成本.

说是抽象的原因是考虑到实际的业务场景每请求的处理开销/代价是不太一样的.
所以规约到一个字节角度的动态参数化类函数描述.

然后假设K个eventloop之后的一部分请求能够得到响应.

也就是会有
latency = K*Time = K*( sys_cost + read_fd*(read_bytes*cost_per_read_byte) + write_fd*(write_bytes*cost_per_write_byte)) / N
->
a*sys_cost + b*N*cost_per_read_byte +c*N*cost_per_write_byte

因为cost都是某种特定场景下的常数.
所以最后可以规约为
latency = const_cost + N*cost_per_hybrid_byte

所以当小于32 CPU的时候,随着CPU增加,每个CPU需要处理的N变小,所以会随着scaleup.
而当>32 CPU的时候,后一项已经significantly小于,const_cost了,所以继续增加并不能有什么改善.

所以这是它的一个缺陷或者说应用场景考虑.

而Go由于runtime的关系,模型会有一些比较不一样的地方.

因为它的IO和channel/mutex等操作会触发groutine等调度.
从逻辑上保证P是burning cpu in the right way.

也就是说,当有类似操作的时候,go会让专门的线程(netpoller/sysmon等)去做blocking的工作,而让M继续执行runtime user level的meaningful code.

所以如果不考虑IO等blocking call的capacity的话.
它的latency大致就是直接的
latency = k*CPU/(N*c)
k为一个CPU提供的请求处理能力常数.
N为请求数,c为每请求需要的cpu资源数

所以简化下就是一个简单的
latency=a*CPU/N
的关系.
性能将随着CPU数的增加而增加.

这个跟benchmark的结果的部分结果也是可解释的.
在CPU/GOMAXPROCSS<40的时候,CPU资源的增加和性能的提升是准线性/sub-linear的.

但是继续增加的话,并不能再进一步地提高性能,甚至从抖动的角度来说,还有一定的degrade.

所以,这里就必须考虑把IO等的调度/capacity考虑进去了.

revise一下就是:
latency = (k*CPU)/(N*c-f*blocking)
f代表一个mean的平均调度周期内的从blocking->ready的G的数量.
以及bloking为对应的执行后续的开销.

intuitive的理解就是,divider是原本能调度的非blocking的user level的G减去必须调度的blocking ready的G.

这样的话,回头解释就是.
当CPU<40的时候,本质上是CPU bounded的,也就是其实并不能完全游刃有余地处理全部请求.
因此能产生的ready的数量是随着CPU数当增加而增加的.
所以是一个sub-linear的关系.

而当大于40的时候,基本上可以解释为是CPU已经不是瓶颈了,而主要是runtine处理IO/调度的能力.
所以这时候的f会相对地大量增加.
如果此时f*blocking是dominated的话,那么CPU的增加就会差不多近线形地被f的增加所抵消.

这样的话,就可以解释为什么继续增加CPU资源的时候,甚至可能会有degrade的情况.

这时候可能可以考虑的就是要么减少G的数量,要么减少syscall等blocking call的数量.

本质上,后者也是减少G的数量.

这个在之前一些版本的实验/探索里大致也是有部分实践可以说明的.

早期的一个实现采用了比较多的channel.
比如读写分开,处理数据分开.处理数据的过程也有channel.

这样的实现是会产生多几个数量级的G的.

所以后来的做法就是缩减了一些到公共channel,以及合并了一些原本独立的IO channel/goroutine.
这样的话,就减少了f和调度器的开销.

另外一个尝试就是把部分的写IO换成buffer io.
也就是合并syscall等部分.

但是现有的API做网络buffer io并不是很友好.
因为你需要手动地flush保证一定的latency的keep system running.

不然可能因为没有flush导致上下游都在等对方的IO.

所以要么就是加timer去flush.
要那么就是嵌套的select,外层non- blocking,然后default分支里再加blocking的select.

无论是哪种方式,都需要额外引入一个channel和goroutine.

这样的话就变成了syscall和goroutine/channel对于scheduler来说孰轻孰重的情况了.

而如果选择后者的话,代码复杂度会上升一些.
加上实际测试的效果似乎并不理想,所以最后这部分还是改为了直接syscall的方式.

当然,这里还有另外一个方向就是用mutex.
也就是write的时候用lock而不是channel.
这样的话,就可以在不用buffer io的前提下,speculate地合并一些写.

加上mutex本身也会触发调度,形式上和channel的效果类似.

但是它的问题在于不scale.

在限制CPU为1或者相对低的情况下,mutex通常能走fastpath.
也就是简单cas就能成功,不会触发调度,所以表现是相当地promising.

但是当CPU数量上去之后,资源抢占变得比较激烈,大多数时候都是slowpath,结果就不是很理想了.

所以这里一个思路就是对于mutex和channel的选择.
在抢占相对没那么严重的情况下,mutex似乎是比chan更好的一个选择.

回到IO的问题上.

这里的本质是Go目前没有一个比较好的感知IO ready情况的一个API.
理想状况下,需要是一个类似
func Write([]byte) <-chan IOResult
的API,
这样的话就能比较方便地做
for{
   ioCh:=w.Write(buf)
   select{
     case r<-ioCh:
       //if r.Err() != nil
     case newBuf <- bufChan:
      // gather more
      //. buf = append(buf,newBuf...)
   }
}
之类的.进一步减少groutine和syscall.

当然,初看起来这个自己实现也就是make一个channel然后go一下的问题.
ioCh := make(...)
go func(){
  defer close(ioCh) 
  size,err:=w.write()...
  //ioChan<- &{size,err}
}()

这里的问题一个是构造g的开销.
一个是不可避免地还是增加了一个g.

尽管看目前的runtime实现对g结构是有个resue/pooling的.
但是,多多少少还是涉及一些初始化的问题/开销.

而且更重要的是形式上来说,相比一个continue polling的goroutine并没有什么优势.
反而可能产生了更多的问题.

如果从runtime层面去做的话,可能就是在netpoll wait/pollDesc wait的相关路径更改/增加新的ready的回调方式了.

比如当前是的路径是挂起当前的g.

而按照上面API设计的话,可能就是netpollready的时候加个special case.
看是否有对应的g或者新的代表return chan的struct field,然后fake一个g或者更直接地,通过 channel kick start对端的g就是了.

当然,实际会更复杂一些.
比如加入之后还需要考虑pollDesc里的各个timer的处理等.


聊聊卡布里尼

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