前言
nbio 项目还包括建立在 nbio 基础上的nbhttp,但这不在我们的讨论范围之内。
与 evio 一样,nbio 也采用经典的 Reactor 模式。事实上,Go 中的许多异步网络框架都是基于这种模式设计的。
我们先看看如何执行 nbio 代码。
(1) 服务器:
我们用nbio.NewGopher() 函数创建新的引擎实例,通过nbio.Config 结构来配置引擎实例,包括:
- Network(网络):使用的网络类型,本例中为 "TCP"。
- Addrs(地址):服务器应该监听的地址和端口,这里是":8888"(监听本地计算机的 8888 端口)。
- MaxWriteBufferSize(最大写缓冲区大小):写缓冲区的最大大小,此处设置为 6MB。
我们还可以进一步探索其他配置。然后,我们通过引擎实例g.OnData() 注册数据接收回调函数,该回调函数会在收到数据时调用。回调函数需要两个参数:连接对象c 和接收到的数据data。在回调函数中,通过c.Write() 方法将接收到的数据写回客户端。
(2) 客户端:
乍一看似乎有点繁琐,实际上服务器和客户端共享同一套结构。
客户端通过nbio.Dial 与服务器连接,连接成功后封装到nbio.Conn 中。这里nbio.Conn 实现了标准库中的net.Conn 接口,最后通过g.AddConn(c) 添加此连接,并向服务器写入数据。服务器收到数据后,其处理逻辑是将数据原封不动发送回客户端,客户端收到数据后,会触发OnData 回调,该回调会检查收到的数据长度是否与发送的数据长度一致,如果一致,则关闭连接。
下面深入探讨几个关键结构。
Engine 本质上是核心管理器,负责管理所有监听器、轮询器和工作轮询器。
这两种轮询器有什么区别?
区别在于责任不同。
监听轮询器只负责接受新连接。当一个新的客户端conn 到达时,它会从pollers 中选择一个工作轮询器,并将conn 添加到相应的工作轮询器中。随后,工作轮询器负责处理该连接的读/写事件。
因此当我们启动程序时,如果只监听一个地址,程序中的轮询次数等于 1(监听器轮询器)+pollerNum。
通过上述字段,可以自定义配置和回调。例如,可以在新连接到达时设置onOpen 回调函数,或在数据到达时设置onData 回调函数等。
Conn 结构代表网络连接,每个Conn 只属于一个轮询器。当数据一次写不完时,剩余数据会先存储在writeBuffer 中,等待下一个可写事件继续写入。
至于poller 结构,这是一个抽象概念,用于管理底层多路复用 I/O 操作(如 Linux 的 epoll、Darwin 的 kqueue 等)。
注意pollType,nbio 默认使用电平触发(LT)模式的 epoll,但用户也可以将其设置为边缘触发(ET)模式。
介绍完基本结构后,我们来看看代码流程。
当启动服务器代码时,调用Start:
代码比较容易理解,分为四个部分:
第一部分:初始化监听器
根据g.network 值(如 "unix"、"tcp"、"tcp4"、"tcp6"),为每个要监听的地址创建一个新的轮询器。该轮询器主要管理监听套接字上的事件。如果在创建过程中发生错误,则停止所有先前创建的监听器并返回错误信息。
第二部分:初始化一定数量的轮询器
创建指定数量(pollerNum)的轮询器,用于处理已连接套接字上的读/写事件。如果在创建过程中发生错误,将停止所有监听器和之前创建的工作轮询器,然后返回错误信息。
第三部分:启动所有工作轮询器投票站
为每个轮询器分配读缓冲区并启动。
第四部分:启动所有监听器
启动之前创建的所有监听器,并开始监听各自地址上的连接请求。
关于轮询器的启动:
分为两种情况。如果是监听轮询器:
监听轮询器等待新连接的到来,并在接受后将其封装到nbio.Conn 中,并将Conn 添加到相应的工作轮询器中。
这里一个有趣的设计是对conn 的管理。该结构是个切片,直接使用conn 的fd 作为索引。这样做的好处是:
- 在连接数较多的情况下,垃圾回收时的负担要比使用 map 小。
- 可以防止序列号问题。
最后,通过调用addRead 将相应的conn fd 添加到 epoll 中。
这里不注册写事件是合理的,因为新连接上没有数据要发送。这种方法避免了一些不必要的系统调用,从而提高了程序性能。
如果启动的是工作轮询器,它的工作就是等待新增conn 事件,并进行相应处理。
这段代码也很简单,等待事件到来,遍历事件列表,并相应处理每个事件。
在EpollWait 中,只有msec 是用户可修改的。通常,我们设置msec = -1 使函数阻塞,直到至少有一个事件发生;否则,函数将无限期阻塞。当事件较少时,这种方法非常有用,能最大限度减少 CPU 占用。
如果想尽快响应事件,可以设置msec = 0,这样EpollWait 就能立即返回,无需等待任何事件。在这种情况下,程序可能会更频繁调用EpollWait,可以在事件发生后立即处理事件,从而提高 CPU 使用率。
如果程序可以容忍一定延迟,并且希望降低 CPU 占用率,可以将msec 设置为正数。这样,EpollWait 就会在指定时间内等待事件发生。如果在这段时间内没有事件发生,函数将返回,可以选择稍后再次调用EpollWait。这种方法可以降低 CPU 占用率,但可能导致响应时间延长。
nbio 会根据事件计数调整msec 值。如果计数大于 0,则msec 设置为 20。
字节跳动的 netpoll 代码与此类似;如果事件计数大于 0 ,则将msec 设置为 0;如果事件计数小于或等于 0,则将msec 设置为-1,然后调用Gosched() 以主动退出当前 goroutine。
不过,nbio 中的自愿切换代码已被注释掉。根据作者的解释,最初他参考了字节跳动的方法,并添加了自愿切换功能。
不过,在对 nbio 进行性能测试时发现,添加或不添加自愿切换功能对性能并无明显影响,因此最终决定将其删除。
事件处理部分
如果是可读事件,则可以通过内置或自定义内存分配器获取相应的缓冲区,然后调用ReadAndGetConn 读取数据,无需每次都分配缓冲区。
如果是可写事件,则会调用flush 发送缓冲区中未发送的数据。
逻辑也很简单,有多少就写多少,如果写不完,就把剩余数据放回writeBuffer,然后在epollWait 触发时再次写入。
如果写入完成,则不再有数据要写入,将此连接的事件重置为读取事件。
主逻辑基本上就是这样。
等等,最初提到有新连接进入时,只注册了连接的读事件,并没有注册写事件。写事件是什么时候注册的?
当然是在调用conn.Write 时注册的。
当 Conn 数据到达时,底层会在读取数据后回调OnData 函数,此时可以调用Write 向另一端发送数据。
当数据未完全写入时,剩余数据将被放入writeBuffer,触发执行modWrite,并将conn 的写入事件注册到 epoll。
总结
与 evio 相比,nbio 没有蜂群效应。
Evio 通过不断唤醒无效的 epoll 来实现逻辑正确性。Nbio 尽量减少系统调用,减少不必要的开销。
在可用性方面,nbio 实现了标准库net.Conn,许多设置都是可配置的,允许用户进行高度灵活的定制。
预分配缓冲区用于读写操作,以提高应用程序性能。
总之,nbio 是个不错的高性能无阻塞网络框架。
参考资料:
[1]Analyzing High-Performance Network Framework nbio in Go:https://levelup.gitconnected.com/analyzing-high-performance-network-framework-nbio-in-go-9c35f295b5ad