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

mai_yang for Go 夜读 · 2020年02月13日 · 231 次阅读

文章来自于:https://reading.developerlearning.cn/reading/65-2019-10-31-go-net/

分享者:潘建锋 @ 亚马逊

观看视频

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 里执行,所以不会有同步问题。

回看视频

-

参考资料


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

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册