Go基于I/O多路复用的TCP协议流解析实践

共 18952字,需浏览 38分钟

 ·

2021-09-13 20:14

《Go经典阻塞式TCP协议流解析的实践》一文中,我们基于Go经典的阻塞I/O模型实现了一个基于TCP流的自定义协议的解析。这种one-connection-per-goroutine模型的优点就是简单、好写以及好理解,降低开发者心智负担。但一旦连接数上来,goroutine的数量就会线性增加。当面对海量连接的场景,这种模型将力不从心:系统中将存在大量goroutine,goroutine调度和切换的开销过多。

那么面对海量连接场景,应该如何解决呢?业界成熟方案:使用I/O多路复用模型。了解Go net包实现的朋友想必都知晓Go在运行时底层使用的也是I/O多路复用,其实现为runtime中的netpoll[1]。goroutine层面获得的net.Conn(无论是Accept的,还是Dial得到的)都展现出“阻塞”的特征,但这些net.Conn底层实现的fd(文件描述符)在netpoll中都是non-blocking(非阻塞)的,Go运行时负责调用epoll等多路复用机制监视这些fd是否可读或可写,并适时唤醒goroutine继续网络I/O操作,这种方式减少了系统调用,也减少了运行Goroutine的M(操作系统线程)因系统调用陷入内核态等待的频率以及因阻塞失去M而不得不去创建新线程的数量。

那么在用户层面建立自己的I/O多路复用的不足在哪里呢?复杂,不好写,不好理解。但似乎也没有其他更好的办法。除非换语言,否则就得硬着头皮上^_^。好在,Go社区已经有几个不错的Go用户层面非阻塞I/O多路复用的开发框架库可供选择,比如:evio[2]gnet[3]easygo[4]等。我们选择gnet。但注意:选择不代表推荐,这里仅是来做这个实践而已,是否使用gnet开发上生产的程序,需要你自己评估确定。

1. 基于gnet开发TCP流协议解析程序

用框架的一个门槛就是你要去学习框架本身。好在gnet提供了几个很典型的examples[5],我们可以基于其中的custom_codec[6]来快速开发我们的TCP流协议解析程序。

下面是基于gnet框架实现custom codec的一个关键循环,了解这个循环,我们就知道在什么位置调用Frame编解码以及packet编解码了,这样决定了后续demo程序的结构:

上面图中右边虚框中的frame编解码、packet编解码以及React是用户需要自己实现的,gnet框架的eventloop.loopRead方法会循环调用frame编解码和React以实现TCP流的处理以及响应的返回。有了这样一张“地图”,我们就可以明确demo程序中各个包的大致位置了。

我们的demo改自gnet的例子custom_codec[7],其main包结构来自于custom_codec:

// github.com/bigwhite/experiments/tree/master/tcp-stream-proto/demo4/cmd/server/main.go

type customCodecServer struct {
 *gnet.EventServer
 addr       string
 multicore  bool
 async      bool
 codec      gnet.ICodec
 workerPool *goroutine.Pool
}

func (cs *customCodecServer) OnInitComplete(srv gnet.Server) (action gnet.Action) {
 log.Printf("custom codec server is listening on %s (multi-cores: %t, loops: %d)\n",
  srv.Addr.String(), srv.Multicore, srv.NumEventLoop)
 return
}

func customCodecServe(addr string, multicore, async bool, codec gnet.ICodec) {
 var err error
 codec = frame.Frame{}
 cs := &customCodecServer{addr: addr, multicore: multicore, async: async, codec: codec, workerPool: goroutine.Default()}
 err = gnet.Serve(cs, addr, gnet.WithMulticore(multicore), gnet.WithTCPKeepAlive(time.Minute*5), gnet.WithCodec(codec))
 if err != nil {
  panic(err)
 }
}

func main() {
 var port int
 var multicore bool

 // Example command: go run server.go --port 8888 --multicore=true
 flag.IntVar(&port, "port", 8888, "server port")
 flag.BoolVar(&multicore, "multicore"true"multicore")
 flag.Parse()
 addr := fmt.Sprintf("tcp://:%d", port)
 customCodecServe(addr, multicore, false, nil)
}

针对上面代码,有两点要注意:

  • customCodecServe的第三个参数我们传入了false,即我们选择同步回复应答,而不是异步回复。
  • 我们将自定义的frame编解码器(实现了gnet.ICodec接口)实例传给了customCodecServer实例,这样后续gnet loopRead调用的就是我们自定义的frame编解码器了。

按上面流程图的顺序,gnet从conn读取的字节流将传递给我们的frame解码器,下面我们看看基于gnet的Frame解码器的实现(我们的自定义协议定义可以参考《Go经典阻塞式TCP协议流解析的实践》一文):

// github.com/bigwhite/experiments/tree/master/tcp-stream-proto/demo4/pkg/frame/frame.go

type Frame []byte

func (cc Frame) Decode(c gnet.Conn) ([]byte, error) {
 // read length
 var frameLength uint32
 if n, header := c.ReadN(4); n == 4 {
  byteBuffer := bytes.NewBuffer(header)
  _ = binary.Read(byteBuffer, binary.BigEndian, &frameLength)

  if frameLength > 100 {
   c.ResetBuffer()
   return nil, errors.New("length value is wrong")
  }

  if n, wholeFrame := c.ReadN(int(frameLength)); n == int(frameLength) {
   c.ShiftN(int(frameLength)) // shift frame length
   return wholeFrame[4:], nil // return frame payload
  } else {
   return nil, errors.New("not enough frame payload data")
  }
 }
 return nil, errors.New("not enough frame length data")
}

上面Frame的Decode实现既负责frame解码,同时也会对frame的当前数据完整性进行校验,如果一个完整的frame尚未就绪,Decode会返回错误,之后gnet还会在连接(conn)可读时再次调用该Decode函数。这里实现的关键就是gnet.Conn.ReadN这个方法,这个方法本质上是一个Peek操作(gnet称之为lazyRead),即只预览数据, 不挪动数据流中的“读指针”的位置。frame未完全就绪时,gnet在底层会使用RingBuffer存放已经到位的frame的部分数据。如果frame所有数据都就绪了,那么Decode会调用gnet.Conn.ShiftN方法来挪动底层RingBuffer的“读指针”的位置,表明这段数据已经被上层读取了。

如果预读取到的frame长度过长(这里代码中的100是一个魔数,仅做demo演示之用,你可以根据实际情况使用frame可能的最大值),则会清空当前缓存并返回错误。(但gnet并没有因此而断开与客户端的连接,这块儿gnet的机制是否合理还有待商榷。)

如果解码顺利,根据我们自定义的协议spec,我们会将frame的payload返回,即从frame的第五个字节开始返回。

从上图看到,frame Decode返回的payload将作为输入数据传给eventHandler.React方法,这个方法也是我们自己实现的:

// github.com/bigwhite/experiments/tree/master/tcp-stream-proto/demo4/cmd/server/main.go

func (cs *customCodecServer) React(framePayload []byte, c gnet.Conn) (out []byte, action gnet.Action) {
 var p packet.Packet
 var ackFramePayload []byte
 p, err := packet.Decode(framePayload)
 if err != nil {
  fmt.Println("react: packet decode error:", err)
  action = gnet.Close // close the connection
  return
 }

 switch p.(type) {
 case *packet.Submit:
  submit := p.(*packet.Submit)
  fmt.Printf("recv submit: id = %s, payload=%s\n", submit.ID, string(submit.Payload))
  submitAck := &packet.SubmitAck{
   ID:     submit.ID,
   Result: 0,
  }
  ackFramePayload, err = packet.Encode(submitAck)
  if err != nil {
   fmt.Println("handleConn: packet encode error:", err)
   action = gnet.Close // close the connection
   return
  }
  out = []byte(ackFramePayload)
  return
 default:
  return nil, gnet.Close // close the connection
 }
}

在React中,我们利用packet包对传入的frame payload进行Decode并处理得到的Packet,处理后将packet响应进行编码(encode),编码后得到的字节序列(ackFramePayload)将作为React的第一个返回值out返回。

frame会对React返回的ackFramePayload进行Encode,编码后的字节序列将被gnet写入outbound的tcp流中去:

// github.com/bigwhite/experiments/tree/master/tcp-stream-proto/demo4/pkg/frame/frame.go

func (cc Frame) Encode(c gnet.Conn, framePayload []byte) ([]byte, error) {
    result := make([]byte, 0)

    buffer := bytes.NewBuffer(result)

    // encode frame length(4+ framePayload length)
    length := uint32(4 + len([]byte(framePayload)))
    if err := binary.Write(buffer, binary.BigEndian, length); err != nil {
        s := fmt.Sprintf("Pack length error , %v", err)
        return nil, errors.New(s)
    }

    // encode frame payload
    n, err := buffer.Write(framePayload)
    if err != nil {
        s := fmt.Sprintf("Pack frame payload error , %v", err)
        return nil, errors.New(s)
    }

    if n != len(framePayload) {
        s := fmt.Sprintf("Pack frame payload length error , %v", err)
        return nil, errors.New(s)
    }

    return buffer.Bytes(), nil
}

这样一个loopRead循环就完成了。我们可以使用《Go经典阻塞式TCP协议流解析的实践》一文中的client对该程序进行测试:

// demo2的client
$./client
2021/07/25 16:35:34 dial ok
send submit id = 00000001, payload=full-bluestreak-207e
the result of submit ack[00000001] is 0
send submit id = 00000002, payload=cosmic-spider-ham-2985
the result of submit ack[00000002] is 0
send submit id = 00000003, payload=true-forge-3552
the result of submit ack[00000003] is 0

// demo4的server
$./server
2021/07/25 16:35:31 custom codec server is listening on :8888 (multi-cores: true, loops: 8)
recv submit: id = 00000001, payload=full-bluestreak-207e
recv submit: id = 00000002, payload=cosmic-spider-ham-2985
recv submit: id = 00000003, payload=true-forge-3552

2. 压测对比

gnet针对内存分配、缓存重用等做了很多优化,我们来将其与阻塞I/O模型程序在性能上做一下简单比较(由于资源有限,我们这里的压测也和上一文中一样,采用100个client连接尽力(best effort)发送,而不是海量连接)。

下面是demo1(阻塞I/O模型未优化)、demo3(阻塞I/O模型优化后)以及demo4(io多路复用模型)的性能对比:

粗略来看,采用gnet I/O多路复用模型的程序(demo4)在性能上平均比阻塞I/O模型优化后的程序(demo3)高出15%~20%。

不仅如此,通过dstat采集的系统监控数据也表明跑demo4时,cpu系统时间(sys)占用也比demo3少了5个点左右:

跑demo3时的dstat -tcdngym输出:

----system---- ----total-cpu-usage---- -dsk/total- -net/total- ---paging-- ---system-- ------memory-usage-----
     time     |usr sys idl wai hiq siq| read  writ| recv  send|  in   out | int   csw | used  buff  cach  free
23-07 17:03:17|  2   1  97   0   0   0|3458B   19k|   0     0 |   0     0 | 535  2475 |1921M  225M 5354M 8386M
23-07 17:03:18| 40  45   5   0   0  11|   0     0 |  66B   54B|   0     0 |  11k   15k|1922M  225M 5354M 8384M
23-07 17:03:19| 39  46   6   0   0   9|   0     0 |  66B 1158B|   0     0 |  12k   18k|1922M  225M 5354M 8384M
23-07 17:03:20| 35  48   7   0   0  11|   0     0 |  66B  462B|   0     0 |  12k   22k|1922M  225M 5354M 8385M
23-07 17:03:21| 39  44   7   0   0  10|   0    12k|  66B  462B|   0     0 |  11k   16k|1922M  225M 5354M 8385M
23-07 17:03:22| 38  45   6   0   0  10|   0     0 |  66B  102B|   0     0 |  11k   16k|1923M  225M 5354M 8384M
23-07 17:03:23| 38  45   7   0   0  10|   0     0 |  66B  470B|   0     0 |  12k   20k|1923M  225M 5354M 8384M
23-07 17:03:24| 39  46   6   0   0   9|   0     0 |  66B  462B|   0     0 |  11k   19k|1923M  225M 5354M 8384M

跑demo4时的dstat -tcdngym输出:

----system---- ----total-cpu-usage---- -dsk/total- -net/total- ---paging-- ---system-- ------memory-usage-----
     time     |usr sys idl wai hiq siq| read  writ| recv  send|  in   out | int   csw | used  buff  cach  free
24-07 20:28:38| 43  42   7   0   0   8|   0    20k|1050B   14k|   0     0 |  11k   18k|1954M  234M 5959M 7738M
24-07 20:28:39| 44  41   9   0   0   7|   0    16k| 396B 7626B|   0     0 |  11k   17k|1954M  234M 5959M 7739M
24-07 20:28:40| 43  42   6   0   0   8|   0     0 | 132B 7044B|   0     0 |  11k   16k|1954M  234M 5959M 7738M
24-07 20:28:41| 42  42   8   0   0   8|   0     0 | 630B   12k|   0     0 |  12k   20k|1955M  234M 5959M 7738M
24-07 20:28:42| 45  41   7   0   0   7|   0     0 | 726B 9980B|   0     0 |  11k   16k|1955M  234M 5959M 7738M

2. 异步回应答

在上面的例子中,我们采用的是gnet同步回应答的方式,gnet还支持异步回应答的方式,即将React中得到的ackFramePayload提交给gnet创建的一个goroutine Worker池,由worker池中的某个空闲goroutine在后续将ackFramePayload编码为一个完整的ackFrame后返回给client端。

要支持异步回应答,我们需要对demo4做几处修改(见demo5),主要修改点都在cmd/server/main.go中。

第一处:main函数调用customCodecServe时,将第三个参数async设置为true:

// github.com/bigwhite/experiments/tree/master/tcp-stream-proto/demo5/cmd/server/main.go

func main() {
 ... ...
 customCodecServe(addr, multicore, true, nil)
}

第二处:在customCodecServer的React方法中,我们得到编码后的ackFramePayload后,不要立即将其赋值给out并返回,而是判断是否要异步返回应答。如果异步返回应答,则将ackFramePayload提交给workerpool,workerPool后续会分配goroutine,并通过gnet.Conn的AsyncWrite将应答写回client。如果非异步,在将ackFramePayload赋值给out并返回。

// github.com/bigwhite/experiments/tree/master/tcp-stream-proto/demo5/cmd/server/main.go

func (cs *customCodecServer) React(framePayload []byte, c gnet.Conn) (out []byte, action gnet.Action) {
 ... ...
 switch p.(type) {
 case *packet.Submit:
  submit := p.(*packet.Submit)
  fmt.Printf("recv submit: id = %s, payload=%s\n", submit.ID, string(submit.Payload))
  submitAck := &packet.SubmitAck{
   ID:     submit.ID,
   Result: 0,
  }
  ackFramePayload, err = packet.Encode(submitAck)
  if err != nil {
   fmt.Println("handleConn: packet encode error:", err)
   action = gnet.Close // close the connection
   return
  }
 default:
  return nil, gnet.Close // close the connection
 }

 if cs.async {
  data := append([]byte{}, ackFramePayload...)
  _ = cs.workerPool.Submit(func() {
   fmt.Println("handleConn: async write ackFramePayload")
   c.AsyncWrite(data)
  })
  return
 }
 out = ackFramePayload
 return
}

除此之外,其他包的代码不变。我们依然还做个压测,看看异步回应答的demo5性能究竟如何!

从上图来看,在这个场景下通过异步回应答的方式,性能反而下降很多,甚至还不如阻塞式I/O模型的程序。对此没有做深究,但猜测可能是应答过多且同时集中回复时workerpool创建了很多goroutine,不仅没有起到池化的作用,还带来的goroutine创建和调度的开销。

3. 小结

在本文中,我们将阻塞式I/O模型换成了I/O多路复用模型,并基于gnet框架重新实现了自定义TCP流协议的解析程序。在同步回应答的策略下,基于gnet开发TCP流协议解析程序相比于阻塞I/O模型程序的性能有一定提升。

本文涉及的所有代码可以从这里下载[8]:https://github.com/bigwhite/experiments/tree/master/tcp-stream-proto

参考资料

[1] 

netpoll: https://github.com/golang/go/tree/master/src/runtime/netpoll.go

[2] 

evio: https://github.com/tidwall/evio

[3] 

gnet: https://github.com/panjf2000/gnet

[4] 

easygo: https://github.com/mailru/easygo

[5] 

gnet提供了几个很典型的examples: https://github.com/gnet-io/gnet-examples

[6] 

custom_codec: https://github.com/gnet-io/gnet-examples/tree/master/examples/custom_codec

[7] 

custom_codec: https://github.com/gnet-io/gnet-examples/tree/master/examples/custom_codec

[8] 

这里下载: https://github.com/bigwhite/experiments/tree/master/tcp-stream-proto

[9] 

改善Go语⾔编程质量的50个有效实践: https://www.imooc.com/read/87

[10] 

Kubernetes实战:高可用集群搭建、配置、运维与应用: https://coding.imooc.com/class/284.html

[11] 

我爱发短信: https://51smspush.com/

[12] 

链接地址: https://m.do.co/c/bff6eed92687



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 80
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报