散落在link包中的一些细节

Go分享 by 达达 at 2015-02-17

最近有几位同行朋友比较热心的帮忙测试和review了link包的代码,从大家的反馈中,我受益颇多,其中最有感触的是,很多设计的细节是无法通过接口观察出来的,其他人通过阅读代码也不一定能理解理解代码的意图,但是这些细节又是极为有用的,比如使用异步发送与否,对程序的吞吐量影响可以达到接近100%,也就是可以提升或降低一倍的吞吐量,所以我整理这篇文章,分析一下link包当中的一些细节,和设计时候的思路,让大家在使用link包或者开发自己通讯底层的时候有个参考。

首先就从异步和同步收发说起,link包的Session有两个类型的消息发送接口,Send和AsyncSend,分别对应同步和异步发送。使用link包的benchmark工具配合echo server做测试,异步消息发送的吞吐量可以接近于同步消息发送的两倍。分析起来,原因在于TCP的全双工模式,当使用同步发送的时候,下一个消息的接收需要等待当前消息发送完成,没办法完全发挥TCP的全双工模式。

而对于AsyncSend方法,这里面还有一个细节。link包的AsyncSend回返回一个AsyncWork,发送者可以选择性的拿着这个AsyncWork等待消息发送过程结束并判断是否发生错误。同时,AsyncSend支持阻塞超时,当阻塞超时为0的时候,AsyncSend在往chan中推送消息时阻塞了,发送过程会立即终止并返回错误,同时Session也会被关闭,如果阻塞超时大于0,AsyncSend在chan阻塞时,会创建一个goroutine用来等待chan的阻塞,直到超时,而一旦超时发生,Session也将被关闭。

接着说说BufferPool的设计。去年早些时候,我曾经尝试发布过项目里用的网络层,后来因为跟项目的结构耦合度太高就关闭了。当时这个网络层有一个叫MemPool的数据结构,它的算法是一次创建一个比较大的[]byte,比如1M,然后每次需要新的[]byte的时候,就从这个大的[]byte里面切割,直到把大的[]byte切割完,再创建一块。通过这个算法,就可以尽量减少零碎的[]byte的创建频率。但是这样做有一个问题,如果切割出来的其中一小块[]byte持续被引用着,那么大块的原始[]byte就无法被释放。所以在link包中,我使用的设计是实现一个总大小可控的BufferPool,然后在每个Session初始化的时候尝试从BufferPool中拿两个Buffer用于收发,当Session销毁时再释放回BufferPool。

有网友问过为什么link包不用sync.Pool,我其实最早引入BufferPool设计的时候就是用sync.Pool,后来发现sync.Pool的总大小不可控,对象总数也不可控,并且Benchmark的时候发现随着GOMAXPROCS的增加有性能下降,于是就自己动手做了一个机遇无锁链表结构的BufferPool,因为只有Session创建和销毁时才会用到BufferPool,所以无锁算法带来的性能提升暂时还是次要的,主要还是BufferPool可以做到总大小可控。

前阵子有位网友尝试在link包中加入RTMP协议,并且我也打算加入WebSocket协议,这就涉及到客户端和服务端的角色区别以及协议的握手过程。所以前阵子又link包的协议改为有状态的,并且引入服务端客户端的角色区别。那么握手协议应该怎么实现呢?有两种方案,一种是自己实现一个特别的net.Listener,这样做的好处是对link包透明。第二个方案是在link包里面实现自己的Protocol接口,在Protocol.New()的时候做握手过程。两种方案目前都没有完成的实践过,我暂时是用第二个方案在实现WebSocket协议。

因为出去玩了一个礼拜,脑袋有些空了,今天只想起来这些点,回头有想到再补充。。。

补充1:昨天忘记一个重要的内容,之前有网友问我为什么link包里面的bufferConn类型起到什么作用。简单来说bufferConn的作用是减少真实IO调用次数。要说其中原理,举一个实际的例子比较直观。以内置的SimpleProtocol协议类型为例,一个消息包包含一个消息头和一个消息主体,消息头是消息主体的长度信息,所以每次完整读取一个消息包的时候,需要先读取消息头,解析后得到消息主体的长度,然后按长度读取消息主体。如果没有用buffio,这个过程就至少要发生两次IO调用。但是实际的网络传输过程中,消息包并不会一点点挤牙膏似的收发,所以利用预读可以在一次IO调用中,尽量多读取一些数据,这样就可以减少IO调用的次数。