0%

gnet 中的一些设计

gnet 是一个基于 Reactor 模式的高性能 go 网络库,工作原理类似 netty 和 libuv,直接使用 epoll 或 kqueue 系统调用来构建网络应用,这使其有着非常好的性能表现。本文章着重讲在阅读 gnet v2.2.3源码中注意到的一些问题。

主从 Reactor 通信方式

主从 Reactor 模式中,主 Reactor 与从 Reactor 模式之间需要通信机制来实现任务的调度。由于主从 Reactor 运行在不同的 goroutine 上,所以很轻松地可以想到使用 channel 进行通信。但是考虑到主 Reactor 与从 Reactor 直接的通信是一读一写的模式,gnet 中使用的是无锁队列的通信机制。这种一读一写的方式是比较经典的无锁队列使用场景了。gnet 中的无锁队列是一个非常经典的实现。

在学习 gnet 框架的时候,恰好我所写的一个玩具项目中也有类似的场景;因为我尝试模仿 gnet 的做法,将 channel 替换为无锁队列。但是在做出这样的修改后,我发现软件的性能竟然比修改之前要下降了。这让我比较疑惑,于是我测试了 gnet 中无锁队列的性能以及 channel 的性能。测试环境为 Macos Ventura, 双核四线程 CPU,8GB 内存,go 1.19 版本;测试的方法一共有两种,一种是测试写入侧完成写入的耗时,第二种是测试读取侧完成读取的耗时。测试结果如下,其中 channel 的缓冲区选取 1000:

测试编号 数据量10W 数据量100W 数据量500W 数据量1000W
queue 完成写入 8 ms 89 ms 647 ms 1228 ms
channel 完成写入 6 ms 64 ms 528 ms 961ms
queue 完成读取 14 ms 159 ms 703 ms 1854 ms
channel 完成读取 11 ms 142 ms 558 ms 1414 ms

根据测试结果,无锁队列的性能确实会比 channel 的性能要低,相同数据量下,使用无锁队列的耗时是使用 channel 耗时的约1.2倍左右。

在得到测试结果后,我以为是我的测试用例有问题,于是花费了很多时间去搜寻资料无果。后来,我尝试替换其他的无锁队列进行测试,于是我选择了github.com/yireyun/go-queue项目进行测试,但测试结果与 gent 中的无锁队列相似,仍然是比 channel 要慢的。于是我开始疑惑,为什么 gnet 中要选择无锁队列来作为消息传递的方式。

在思考无果后,我又开始查询资料,最终找到了可能的答案。这是 go-queue 项目中的一个 issue,它指出 go-queue 会比官方的 channel 速度要慢约10%左右,在其中的一个回复中有如下内容:

yireyun commented on Jul 25, 2022

已经在 MacPro M1 基于go1.17.12 上确认了”Chan 比 Queue 快”;并没有用之前机器和go版本回归测试,初步结论是 chan 比以前快多了; go1.8.3 的 chan 在高并发情况下,性能会急速下降,但在 go1.17.14 上发现高并发,chan性能下降缓慢。

go-queue 确实是一个四年前的项目,其测试环境为 go1.8.3,这让我回想起在知乎曾经看到的一篇文章,“Golang号称高并发,但高并发时性能不高”,里面有这样一段内容:

管道chan吞吐极限10,000,000,单次Put,Get耗时大约100ns/op,无论是采用单Go程,还是多Go程并发(并发数:100, 10000, 100000),耗时均没有变化,Go内核这对chan进行优化。

而 gnet v2 是基于 go 1.11 开发的,因此我猜测 gnet 这里的设计可能是因为 channel 在低版本 go 中性能表现较差。而在高版本 go 中,数据量 10W 左右的级别无锁队列与 channel 并没有表现出较大的性能差异,因此这里并没有对其进行修改。毕竟主从 Reactor 之间更多地只会传递 *conn 指针,很难会出现 10W 以上这种级别的新连接同时到达的情况。

连接管理优化

这个问题在 gnet 的 issue 中有提到,目前解决方案还没有加入到正式版本中。gnet 中,每一个从 Reactor 都需要管理分配到的所有连接,为了区分这些连接,gent 目前采用的是哈希表的方式,将每一个连接的 [file descriptor,*conn] 注册到哈希表中。

1
2
3
4
5
6
7
8
9
10
type eventloop struct {

...

connCount int32 // number of active connections in event-loop
udpSockets map[int]*conn // client-side UDP socket map: fd -> conn
connections map[int]*conn // TCP connection map: fd -> conn

...
}

这种设计在非 GC 语言中可能没有问题,但是在 GC 语言中可能会带来性能问题。GC 扫描对于连续的数据结构是非常快的,但是对于哈希表这种非连续存储的数据结构就会相对较慢。如果使用值存储的形式,哈希表会为键值对分配出一块的连续的内存,而使用指针存储时,值会逃逸到堆上,这样在扫描时就无法采取连续扫描的方式。如果在此基础上,再使用指针来存储比较大的对象,可能会导致扫描哈希表的耗时大大增加。

由于 gnet 主要面对的是类似 redis 和 HaProxy 的场景,这种场景下应用层的处理时间很短,网络库的性能对软件的整体性能影响非常大。而恰好这种情景都是作为后端的基础架构来使用的,客户端数量可能会非常多。当服务端需要处理的连接数较大时,直接使用哈希表来存储指针可能会造成 GC 耗时增加,从而造成比较严重的性能劣化。

gnet 后期可能会依据 bigcache 的模式做出优化,将哈希表更改为二级索引的数据结构,从而实现 GC 的线性扫描。

1
2
3
4
5
6
7
// 原方案
map[int]*conn

// 优化思路,fd 作为索引在哈希表中存储slice下标
map[int]int
// 在 slice 中存储 *conn 指针
slice[]*conn