grpc-go脑爆-性能

最近在研究grpc-go的源码。

http2的协议内容。

断断续续看了近两周,初始阶段一直在跟着client和server的流程走。

基于tcp传输,使用go标准库中的TcpListener,开始Accept连接,单goroutine负责接收新连接。

h2中只有长连接,且引入stream和frame在协议上让连接变成有状态的,连接两端的endpoints使用单连接发送数据,发送数据仅受flow-control的控制,流控分stream level和connection level,这样做的目的是让同connection上的stream之间不过于互相干扰,因为connection上的stream是独立的,有自己的write quota,不同stream分开管理。

而http1.1中单request独占connection,肯定不会互相干扰,但是这样的之所以有h2就是连接建立开销+连接独占和共享连接带来的竞争的trade off,相信google是经过大量试验得到的经验协议。

每个新连接对应一个新goroutine,该goroutine内部在net.Conn之上封装frame读取和stream管理。frame管理使用golang开发组提供的http2库。注意单连接只有一个goroutine负责读入所有frame,新stream是通过headerFrame带新的streamId触发创建的。frame的读取本质上是通过net.ConnRead接口积累数据。虽然通过http2库的framer对象能让使用方以frame为单位读取,但这里是不是会成为瓶颈。单goroutine从tcp连接上读取[]byte的速度足够快么?

每个stream都会以headerFrame开头,这个frame到来,会触发上面提到的goroutine创建新的子goroutine处理stream,相当于每个stream的处理工作由单独的goroutine负责。这里负责的工作只有一部分。处理流程很常规,阻塞等待dataFrame接收完毕,解压后,反序列化[]byte,得到req对象,传递给用户自己定义的处理方法,得到结果,写入controlBuffer写入之后的处理

headerFrame导致stream创建后,req数据还没有到达完全,所以该stream的处理需要阻塞,这个阻塞的时间依赖于单goroutine是否能足够快拿到所有请求体,并写入recvBufferrecvBuffercontrolBuffer相同结构,只是这个buffer专门用来处理请求存储,当在dataFrame中发现END_STREAM被设置后,向buffer中写入io.EOF,这个错误会返回给阻塞点,然后就开始调用用户的方法。

单连接读取frame的是单goroutine,这块是否足够快,怎么定义足够快?我们将server请求处理的的整个流程分成不同阶段。

请求接收时间,从receiver收到headerFrame建立stream的时刻开始,到所有包含END_STREAM的dataStream结束这块时间。这个时间受什么影响呢?

sender发送时间,首先考虑sender,请求体是一个[]byte,将请求体分成多个frame发出去,frame首先集中在本地buffer中,直到到达一定数量才flush出去,这个buffer包含很多stream的frame,每个pkg经过网络传输,receiver的tcp/http层的解析,通过http2的ReadFrame方法得到。sender写入请求体到controlBuffer中,这个buffer是否清空的足够快,这个buffer是队列,先进先出,且buffer空间无限,但是只有一个goroutine名字叫loopyWriter负责处理buffer中的各种数据,这里仅讨论需要发送出去的headerFrame和dataFrame,是否足够快的写入到tcp的net.Conn中?一旦frush,我们可以认为frame就在通往server的链路上了。

frame传输时间,从sender flush开始计时,receiver收到headerFrame停止。

headerFrame读取时间,传输过来的frame先进入server的recvBuffer,recvBuffer从属于stream,frame进入后直接分入不同的stream,stream又是不同的goroutine处理,也就是说frame一旦分配给相应的stream + stream内部读取的时间就是本环节描述的时间。

从这个时刻开始,之前提到的阻塞点就解放了。开始走decomp + codec,然后走用户逻辑,最后得到回复写入controlBuffer,注意这里,又一次进入buffer。

我描述完所有frame的阶段,那么再回想下哪里可能成为瓶颈。

controlBuffer中可能堆积过多的frame,loopyWriter提取frame并存入framer的速度比不上请求的发起速度,这样会导致controlBuffer中堆积的frame暴增。这与压测软件的施压模式有关系,想象最简单的压测工具,启动多个线程,每个线程同时开始访问服务器,并连续访问10000次。与单线程访问1w次有什么区别?后者请求之间是独立的,顺序写入tcp连接,当然这样也有可能打到BDP的最大值充分利用tcp连接。但是当endpoints之间的BDP相当大的情况下,请求的处理速度假设恒定,那么前者多线程更可能打到BDP的最大值,充分利用tcp连接。tcp连接利用的越充分,当处理没有瓶颈的时候,qps就越高。

压测就是要衡量endpoints之间的qps,qps要想达到最大,处理速度和BDP能后匹配是关键,如果server处理速度慢些,BDP又被sender打到很高,那么时延就会上升,因为请求体在pending状态,都没有被读取到app层。如果server处理速度飞快,sender又不能将BDP提升上去,那么tcp连接利用率不够,qps也没有达到理想值。所以我们提升sender的并发数,是为了更快的向tcp连接写入数据提升BDP,让tcp连接逼近饱和,一旦处理不过来,因为h2有流控,sender发都发不出来,qps自然就慢。

再回到sender的controlBuffer是否可能堆积的问题,堆积反应的是receiver处理速度的不够。所以暂时断定这里不是根本原因。

那么网络传输可能是瓶颈么,就那千兆网举例,请求体4k,单纯的qps能达到 1000m/4k 每秒,看看多少万qps,所以断定这个不是原因。

server的单goroutine是不是不能尽快的将frame读到不同stream的recvBuffer中,读取速度是可以衡量的,写入速度也可以衡量,而且读取速度应该是恒定的,我们看sender在读取速度恒定后,是否写入的速度还会增加,导致这部分frame因为server的读取慢而增加延迟,理论上两边应该是比较匹配的,不应该不一致。如果是这个原因,那么就是grpc框架不能通过流控,让server处于一个稳定的状态。

假设sender的写入和receiver读取速度相匹配。那么就上升到一大堆stream的处理耗时上。这些耗时主要是cpu繁忙程度,用户业务逻辑是否不是最优等等。通常我们一上来就看cpu/内存,当然大部分框架都是经过生产磨练和开发人员打磨的,所以比较稳定,将不稳定因素放开给应用逻辑,这种算好的框架。但也让应用开发者对于请求的处理流程完全懵逼。也有查看网络io的,我网络io高与低到底代表这当前endpoints交互的什么样一个状态,可能说网络带宽被打满,我们认为是限制qps的一个原因,这个前提是处理速度超级快,这个时候通常qps已经非常高了,内网soa不会有这个事情。所以一般看这个都是胡扯。

之后,我会根据上面的几个疑点,进行benchmark,并在grpc中做一个统计的pkg,专门用于监控框架是否能稳定的控流,还有为app层的开发人员提供专业的建议。希望能解决app层开发者最常见的问题:

  • cpu压不上去,为什么?
  • 到底什么限制了我的qps?
grpc-go脑爆-性能
Share this