IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 网络协议 -> Go基于I/O多路复用的TCP协议流解析实践 -> 正文阅读

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

《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


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强。在2021年上半年,部落将策划两个专题系列分享,并且是部落独享哦:

  • Go技术书籍的书摘和读书体会系列

  • Go与eBPF系列

欢迎大家加入!

Go技术专栏“改善Go语?编程质量的50个有效实践[9]”正在慕课网火热热销中!本专栏主要满足广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订 阅!


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用[10]”在慕课网热卖中,欢迎小伙伴们订阅学习!


我爱发短信[11]:企业级短信平台定制开发专家 https://51smspush.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展;短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址[12]:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 - https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx

  • 微信公众号:iamtonybai

  • 博客:tonybai.com

  • github: https://github.com/bigwhite

  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

参考资料

[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

  网络协议 最新文章
使用Easyswoole 搭建简单的Websoket服务
常见的数据通信方式有哪些?
Openssl 1024bit RSA算法---公私钥获取和处
HTTPS协议的密钥交换流程
《小白WEB安全入门》03. 漏洞篇
HttpRunner4.x 安装与使用
2021-07-04
手写RPC学习笔记
K8S高可用版本部署
mySQL计算IP地址范围
上一篇文章      下一篇文章      查看所有文章
加:2021-08-02 11:07:19  更:2021-08-02 11:09:03 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/25 17:54:12-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码