第 65 期 2019-10-31 Go 网络编程:Go 原生同步网络模型解析 vs Multi-Reactors 异步网络模型

观看视频

Go 夜读第 65 期 Go 原生网络模型 vs 异步 Reactor 模型

本期 Go 夜读是由 Go 夜读 SIG 核心小组邀请到潘建锋给大家分享 Go 原生网络模型 vs 异步 Reactor 模型,以下是本次分享的部分内容和 QA 。

潘建锋,曾任职腾讯、现亚马逊在职。Go 语言业余爱好者,开源库 gnetants 作者。

引子

我们都知道 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

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:

  1. 潘少说go-net原生的网络模型相当于单reactor的模型,每一个连接一个goroutine来处理,由go的调度器实现高并发,这样应该也能利用上多核CPU的吧?为什么性能比multi-reactors的方式差这么多?
  2. multi-reactors的主reactor万一挂了,怎么办?(类似单点故障问题)
  3. 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 里执行,所以不会有同步问题。

回看视频

参考资料


更多原创文章干货分享,请关注公众号