Go 夜读第 65 期 Go 原生网络模型 vs 异步 Reactor 模型
本期 Go 夜读是由 Go 夜读 SIG 核心小组邀请到潘建锋给大家分享 Go 原生网络模型 vs 异步 Reactor 模型,以下是本次分享的部分内容和 QA 。
潘建锋,曾任职腾讯、现亚马逊在职。Go 语言业余爱好者,开源库 gnet 和 ants 作者。
引子
我们都知道 Golang 基于 goroutine 构建了一个简洁而优秀的原生网络模型,让开发者能够用同步的模式去编写异步的逻辑:goroutine-per-connection 模式,极大地降低了开发者编写网络应用时的心智负担,而且借助于 Go Scheduler 对 goroutines 的高效调度,这个原生网络模型足以应对绝大部分的应用场景。
然而,在工程性上能做到如此高的普适和兼容,给开发者提供如此简单易用的接口,其背后必然是基于非常复杂的封装,做了很多取舍,放弃了一些『极致』的概念和设计。事实上 Golang 的 netpoll 底层就是基于 epoll/kqueue/iocp 这些系统调用来做封装的,最终暴露出 goroutine-per-connection 这样的网络编程模式给开发者。
在绝大部分应用场景下,我推荐大家还是遵循 Golang 的 best practices,以这种模式来构建自己的网络应用,然而,在某些极度需要提高性能、节省资源以及技术栈必须是原生 Go (不考虑 C/C++ 写中间层供 Go 调用)的场景下,我们可以考虑自己构建 Reactor 网络模型。那么,Reactor 模型相对原生模型有哪些优势和弊端呢?我开发了一个基于事件驱动机制的实验性质的异步网络框架:gnet,其在性能和资源占用上都远超 Go 原生 net 包(少数特定的应用场景),通过解析这个框架和 Go 原生网络模型,我们来一一分析~~
预备知识:epoll、非阻塞 IO、IO 多路复用,Linux IO 模式及 select、poll、epoll 详解
分享 Slides
https://taohuawu.club/static_res/html/webslides/gnet/gnet.html
Q&A
Q1: 为什么 gnet 会比 Go 原生的 net 包更快?
答:Multi-Reactors 模型相较于 Go 原生模型在以下场景具有性能优势:1. 高频创建新连接:我们从源码里可以知道 Go 模式下所有事件都是在一个 epoll 实例来管理的,接收新连接和 IO 读写;而在 Reactors 模式下,accept 新连接和 IO 读写分离,它们在各自独立的 goroutines 里用自己的 epoll 实例来管理网络事件。2. 海量网络连接:Go net 处理网络请求的模式是 goroutine per connection,甚至是 multiple goroutines per connection,而 gnet 一般使用与机器 CPU 核心数相同的 goroutines 来处理网络请求,所以在海量网络连接的场景下 gnet 更节省系统资源,进而提高性能。3. 时间窗口内连接总数大而活跃连接数少:这种场景下,Go 原生网络模型因为 goroutine per connection 模式,依然需要维持大量的 goroutines 去等待 IO 事件 (保持 1:1 的关系),Go scheduler 对大量 idle goroutines 的调度势必会损耗系统整体性能;而 gnet 模式下需要维护的仅仅是与 CPU 核心数相同的 goroutines,而且得益于 Reactors 模型和 epoll/kqueue,可以确保每个 goroutine 在大多数时间里都是在处理活跃连接。4. 短连接场景:gnet 内部维护了一个内存池,在短连接这种场景下,可以大量复用内存,进一步节省资源和提高性能。
Q2: Go netpoll 源码里的 waitRead 方法到底是起什么作用?
答:看源码:
// func (fd *FD) Read(p []byte) (int, error)
for {
n, err := syscall.Read(fd.Sysfd, p)
if err != nil {
n = 0
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
// On MacOS we can see EINTR here if the user
// pressed ^Z. See issue #22838.
if runtime.GOOS == "darwin" && err == syscall.EINTR {
continue
}
}
...
// func netpollblock(pd *pollDesc, mode int32, waitio bool) bool
// need to recheck error states after setting gpp to WAIT
// this is necessary because runtime_pollUnblock/runtime_pollSetDeadline/deadlineimpl
// do the opposite: store to closing/rd/wd, membarrier, load of rg/wg
if waitio || netpollcheckerr(pd, mode) == 0 {
gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
}
通过分析 conn.Read (),我们知道这个方法是同步的,但从源码我们可以看出,Go 使用的是非阻塞 IO,所以调用 syscall.Read 的时候并不会阻塞,所以实际上它是通过 waitRead 这个方法来实现阻塞的:netFD 的 Read 操作在系统调用 Read 后,当遇到 syscall.EAGAIN 时,waitRead 里面的 netpollblock 会调用 gopark 将当前读这个网络描述符的 goroutine 给 park 住,直到这个网络描述符上的读事件再次发生为止,唤醒 goroutine,waitRead 调用返回,回到外层的 for 循环继续执行。conn.Write 方法和 Read 的实现原理是一样的,都是在发生 syscall.EAGAIN 错误的时候将当前 goroutine 给 park 住直到 socket 再次可写为止。
Q3: Go 的网络模型有『惊群效应』吗?
答:没有。我们看下源码里是怎么初始化 listener 的 epoll 示例的:
var serverInit sync.Once
func (pd *pollDesc) init(fd *FD) error {
serverInit.Do(runtime_pollServerInit)
ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
if errno != 0 {
if ctx != 0 {
runtime_pollUnblock(ctx)
runtime_pollClose(ctx)
}
return syscall.Errno(errno)
}
pd.runtimeCtx = ctx
return nil
}
这里用了 sync.Once 来确保初始化一次 epoll 实例,这就表示一个 listener 只持有一个 epoll 实例来管理网络连接,既然只有一个 epoll 实例,当然就不存在『惊群效应』了。
Q4: Multiple Reactors + Goroutine-Pool Model 这个模式,把阻塞的任务放入 Goroutine-Pool,但是如果 Response 依赖于阻塞任务返回的结果 (比如依赖于一个 http 请求结果),这种情况 Goroutine-Pool 是不是意义不大了?
gnet 提供了异步写的 API: AsyncWrite,一般都是在 goroutine pool 里处理完阻塞逻辑之后直接调用这个方法把 response 写回 socket,总之,原则就是不能阻塞 eventloop goroutine,也就是 gnet.React 方法。
Q5:
潘少说 go-net 原生的网络模型相当于单 reactor 的模型,每一个连接一个 goroutine 来处理,由 go 的调度器实现高并发,这样应该也能利用上多核 CPU 的吧?为什么性能比 multi-reactors 的方式差这么多?
multi-reactors 的主 reactor 万一挂了,怎么办?(类似单点故障问题)
multi-reactors + goroutine pool 的模式下,subreactor 负责输入输出,goroutine pool 负责计算,若某个任务的数据量比较大,从 subreactor 到 goroutine pool 或从 goroutine pool 到 subreactor 的数据传输成本会不会很大?
答:问题 1 参见上面第一个我回答的问题;问题 2 说的 Reactor 单点问题的确是存在的,因为 gnet 使用的是主从 Reactors 模式,main reactor 只有一个,所以的确存在这个潜在的问题,解决办法也有:使用多 acceptors,利用 SO_REUSEPORT 参数让内核帮你做 load balancing 避免惊群;至于问题 3 :并不存在数据传输成本,从当前 eventloop goroutine 也就是 gnet.React 方法里一般是用 closure 闭包的方式提交任务到 goroutine pool 的,是引用方式。
Q6: goroutine pool 如何把数据送回到 subreactor?
当你在独立的 goroutine 里完成你的阻塞逻辑之后得到了 response 数据,直接调用 AsyncWrite:
func (c *conn) AsyncWrite(buf []byte) {
if encodedBuf, err := c.loop.svr.codec.Encode(buf); err == nil {
_ = c.loop.poller.Trigger(func() error {
if c.opened {
c.write(encodedBuf)
}
return nil
})
}
}
通过 closure 的方式,写一个唤醒事件到 epoll,同时传一个 func () error 到任务队列,在 sub reactor 的那个 goroutine 里执行这个函数,把数据写回 client。
Q7: 在等待传回的这段时间,subreactor 是不是还是得阻塞着,无法处理其他请求
不会阻塞啊,此时 React 方法已经返回了,你的阻塞逻辑是提交到 goroutine pool 里处理,处理完直接调用 AsyncWrite 异步写回去了,方式就是我上面说的,写一个唤醒事件到 epoll,在 eventloop goroutine 里执行,所以不会有同步问题。
回看视频
https://www.bilibili.com/video/av74598921
https://youtu.be/4QurJJHuxaQ
参考资料
A Million WebSockets and Go(https://www.freecodecamp.org/news/million-websockets-and-go-cc58418460bb/)
Going Infinite, handling 1M websockets connections in Go(https://speakerdeck.com/eranyanay/going-infinite-handling-1m-websockets-connections-in-go)
百万 Go TCP 连接的思考: epoll 方式减少资源占用(https://colobu.com/2019/02/23/1m-go-tcp-connection/)
gnet: 一个轻量级且高性能的 Golang 网络库(https://taohuawu.club/go-event-loop-networking-library-gnet)