深入解析非阻塞Golang IO框架nbio

在本篇文章中,我们将深入分析一个高性能的网络编程框架:nbio。

nbio项目里也包含了在nbio之上构建的nbhttp,这个不在我们讨论范围。

nbio同样采用了经典的Reactor模式,事实上,Go语言中的许多异步网络框架都是基于这种模式设计的。

老规矩,先运行nbio程序代码,

Server:

7a404bcaad263c9c12f3469c47681d33.png

使用nbio.NewGopher()函数创建一个新的Engine实例。传入nbio.Config结构体来配置 Engine 实例,包括:

  • Network: 使用的网络类型,这里是tcp。

  • Addrs: 服务器要监听的地址和端口,这里是 ":8888"(即监听本机的8888端口)。

  • MaxWriteBufferSize: 写缓冲区的最大大小,这里设置为 6MB。

其他配置可以自行查看。

然后使用g.OnData()方法为 Engine实例注册一个数据接收回调函数。这个回调函数在收到数据时被调用。

回调函数接收两个参数:连接对象c和收到的数据 data。在回调函数内部,我们使用c.Write()方法将收到的数据原样写回给客户端。

Client:

e4ee9c7df1eaf6364f212c29ad9f13af.png

乍一看有点麻烦。其实是服务端和客户端共用了一套结构。

客户端通过nbio.Dial连接服务端,连接成功封装成nbio.Conn,AddConn这个conn。

这里的nbio.Conn是实现了标准库的net.Conn 接口的

接着调用Write往服务端写数据,当服务端接收到数据后,Server端的处理逻辑是把数据原样发送给客户端,当客户端接收到数据一样OnData会被回调,最后客户端主动关闭这个连接。

下面来看几个主要结构。

45692c1f05e30e264cc592215f5ed9cb.png

Engine本质上就是核心管理器。会管理所有的listener poller 和worker poller。这两种poller有什么区别吗?

区别在职责上。

listener poller只负责accept新的连接,当一个新的客户端conn到来时,会从pollers挑选一个worker poller,然后把conn加入到对应的worker poller,之后worker poller负责处理此conn的读写事件。

所以当我们启动程序的时候,如果只监听一个地址的情况下,那么程序的poll数= 1(listener poller) + pollerNum。

从上面的字段也可以看出,你可以自定义一些配置和回调。比如你可以设置当新连接到来时的回调函数onOpen,也可以设置一个conn数据到来时的回调函数onData等。

cc3beb0a2178823d56ed2bd2579e62e0.png

Conn结构体,用于表示一个网络连接。一个conn只属于一个poller。对应的writeBuffer:当数据一次没写完时,剩下的先存在writeBuffer,等待下次可写事件到来继续写入。

1cfa218158611e20259decbc498c6105.png

至于poller结构,这里就是一个抽象的概念,用于管理底层的多路复用 I/O(如linux的epoll、darwin上的kqueue 等)

注意这里的pollType,nbio默认epoll采用的是水平触发(LT),当然用户也可以设置成边缘触发(ET)。

介绍完基本的结构,接下来进入代码的流程。

上面服务端的代码,当你调用Start启动程序后,

8efb6ad21f37a3cd6b785dac40460d23.png

代码还是易懂的,整体看就四个部分。

第一部分:初始化 listener

根据 g.network 的值(如 "unix", "tcp", "tcp4", "tcp6"),为每个要监听的地址(g.addrs)创建一个新的 poller。这里的 poller主要用于管理监听套接字上的事件。如果创建 poller 时出错,将停止之前创建的所有监听器并返回错误。

第二部分:初始化一定数量的 poller

根据pollerNum创建对应个数的worker poller。这些poller用于处理已连接套接字上的读/写事件。如果在创建过程中遇到错误,将停止所有监听器和之前创建的工作 poller,然后返回错误。

第三部分:启动所有的 worker poller

为每个工作 poller 分配一个读缓冲区(由 g.readBufferSize 决定大小),并发地启动这些 poller。

第四部分:启动所有的 listener

启动所有之前创建的监听器。开始监听对应地址的连接请求。

至于poller的启动,

1de34ee854211a0484aef032f21ba450.png

分为两种,如果是一个listener poller,

a6425beb6a71393dc99d36c16d13da7d.png

listener poller 就是等待新连接,然后通过NBConn封装成nbio conn结构,最后通过取模操作获取其中一个woker poller。把连接加入到对应的poller中。

a3c8218a92739a93cb4762c2c252c010.png

这里一个有趣的设计,在管理conns上,结构是slice,作者直接使用的conn的fd来作为下标。

这样还是有好处的,

  • 连接超多的情况下,GC的时候负担会比map小。

  • 能防止串号问题。

最后通过调用addRead把对应的conn fd加入到epoll。

ab0c43076c71ae7127d9a0dc2bd57f29.png

这里没有注册写事件是合理的,因为在新连接上还没有收到任何数据,所以暂时没有需要发送的数据。这种做法可以避免一些不必要的系统调用,从而提高程序的性能。

如果是worker poller的启动,它的工作就是等待加入的那些conns的事件到来,进行对应的处理。

60304a699e1bbfab4be554408cf52d2d.png

这段代码也很好理解。等待事件到来,遍历对应的事件列表,判断事件类型,相对应的处理。

81cf7a35a73cf834307eaaf80dd0f01a.png

EpollWait中只有msec是可以用户动态修改的,通常情况下,我们主动调用EpollWait都会设置msec=-1,msce=-1会使得函数一直等待,直到至少有一个事件发生,否则的话一直阻塞。这种方法在事件发生较少的情况下非常有用,因为它可以最大限度地减少 CPU占用率。

如果希望尽可能快速响应事件,可以将msec设置为 0。这将使 EpollWait立即返回,不等待任何事件。这种情况下,你的程序可能会更频繁地调用EpollWait,但能够在事件发生后立即处理它们。当然,这就会导致CPU占用率较高。

如果你的程序可以承受一定的延迟,并希望减少 CPU占用率,可以将msec设置为一个正数。这将使得EpollWait在指定的时间内等待事件。如果在这段时间内没有事件发生,函数将返回,你可以选择在稍后再次调用EpollWait。这种方法可以降低 CPU占用率,但可能会导致较长的响应时间。

nbio对应这个值的调整策略是:当事件数量大于0时,msec=20(这个20应该是作者测试后综合考量?)。

字节跳动的netpoll代码是这样的,如果事件数量大于0,会设置msec为0。如果事件小于等于0,设置msec=-1,然后调用Gosched()使得当前Goroutine主动让出P。

74800e7ce1afe962b17f8b2fbf410d09.png

然而,nbio中主动切换的代码已经被注释掉了。根据作者在issue中的解释,最初他参考了字节的方法加入了主动切换。但在对 nbio 进行性能测试时,发现加入与不加入主动切换对性能没有明显区别,因此最终决定将其移除。

事件的处理部分,

如果是可读事件,你可以通过内置或者自定义的内存分配器来得到相应的buffer,然后调用ReadAndGetConn读取数据,而不需要每次都申请一遍buffer。

如果是可写事件的话,会调用flush把buffer里未发送的数据发送出去。

c74053db5c3c0c10d6edecb0594b590c.png

逻辑也很简单,写多少是多少,写不进去把剩下的重新放入writeBuffer,下轮epollWait触发再写。

如果写完了,那就没数据可写了,重置这个conn的事件为读事件。

主逻辑就差不多是这样了。

等等,我们一开始说一个新连接进来的时候,我们对一个连接只注册了读事件,没注册写事件,写事件是什么时候注册的?

当然是你调用conn.Write的时候,

e758d6f9523a957082f73ff442a1acdf.png

当conn的数据到来时,底层读完数据,会回调OnData函数,此时你可以调用Write向对端发送数据,

85d3b674c046a65f448d87befa26dc69.png

当数据没写完,就把剩余数据放入到writeBuffer,这样就会触发执行modWrite,就会把conn的写事件注册到epoll中。

总结

相较于evio,nbio不会出现惊群效应。

evio是通过不断无效的唤醒epoll,来达到逻辑的正确性。而nbio是尽可能的减少系统调用,减少无谓的开销。

易用性上,nbio实现了标准库net.Conn,同时很多设置可配置化,用户可以自由定制,自由度较高。

读写上会使用预先分配的buffer,提高应用性能。

总之,nbio是一款不错的高性能非阻塞的网络框架。

- END -

扫码关注公众号「网管叨bi叨」

给网管个星标,第一时间吸我的知识 👆

网管整理了一本《Go 开发参考书》收集了70多条开发实践。去公众号回复【gocookbook】领取!还有一本《k8s 入门实践》讲解了常用软件在K8s上的部署过程,公众号回复【k8s】即可领取!

觉得有用就点个在看  👇👇👇

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值