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的处理等.


没有评论:

发表评论

爽文

去看了好东西. 坦白说,多少是带着点挑刺的味道去的. 毕竟打着爱情神话和女性题材的气质,多多少少是热度为先了. 看完之后倒是有些新的想法. 某种程度上来说,现在的年轻人或者说声音就像小叶. 只要说点贴心的话就能哄好. 也是那种可以不用很努力了. 留在自己的舒适区避难所小圈子抱团就...