gaio小记

xtaci/gaiogithub.com图标

问题的提出

https://github.com/golang/go/issues/15735github.com

此链接是集中讨论这个问题的github issue。

使用golang开发一个网络服务器,通常的流程是:

  1. 创建一个net.Listener。
  2. 从net.Listener去Accept得到一个net.Conn。
  3. go func(net.Conn)开启两个独立的goroutine去分别处理读写。
  4. 分别在reader goroutine和writer goroutine 中,分配一个4KB的buffer,用于收发数据。

这是golang服务器开发的标准阻塞模型,从服务器端的负载角度而言, 在连接数很低的时候,阻塞模型能带来大量的开发便利,降低心智成本。但在承载大量链接的时候,阻塞模型的缺陷就很明显了,例如对于一个接入10K个链接的服务器,我们可以计算一下其基本的内存开销为:

10K *(4KB读+4KB写+2KB reader stack+ 2KB writer stack) = 120MB

虽然服务器有大量内存,但这个内存用量需要将golang做到嵌入式系统时就非常困难。

这还不包括20K个goroutine运行队列管理和调度的开销,例如,对于大量的短消息,几十个字节,或一两百字节,goroutine上下文切换成本会高于数据的处理成本,例如消息转发场景 Cost_{cs} > Cost_{payload} ,这种情况是完全没有必要进行20K goroutine之间的 执行上下文切换的(CPU执行路径的频繁改变)。

如果采用非阻塞+Reactor,或非阻塞+Proactor方式。那么我们可以做到:

仅仅在某个net.Conn有数据的的时候,我们才去分配这个4KB的Read buffer,或者预先全局只分配一个4KB Buffer,顺序去对所有可读(EPOLLIN)的链接进行Read操作, 由于所有链接都可重用这个Buffer,这样即可省掉 10K*(4KB buffer + 2KB stack) = 60MB 内存。(注意,使用全局内存是牺牲并行处理代价的。)

goroutine上下文切换成本的控制和内存控制,是gaio的开发初衷,用于解决高并发下,尤其是有大量小包交换时的网络接入。

选型

采用reactor还是proactor更适合golang做网络服务器开发呢?市场的同类寥寥无几,调查了github star最高的evio 后,总结出reactor模式在golang开发中的存在根本缺陷,对于evio这样一种数据处理模型,存在以下几个问题:

events.Data = func(c evio.Conn, in []byte) (out []byte, action evio.Action) {
	out = in
	return
}
  1. 始于EPOLLIN事件的数据处理流水线,由于不得不及时接收(不接收会阻塞所有socket接收),会导致数据接收部分过分争夺计算资源(调度),或内存资源,缺乏根据实际服务器负载状况的反向传导机制。(期望:可控的CPU资源分配,在高业务负载的时候也调整接收速度。)
  2. 缺乏对独立流的Backpressure机制: evio epoll的level trigger是探测整体的可读状态,如果出现可读但不读取,那么epoll会反复告知应用有数据可读,导致CPU满载。于是:不能通过不读取socket,引导流控反向传导,选择性的让某个链接降低发送速度,或者暂停发送。(期望:可控的收取,服务器业务逻辑去决定下一次收取什么数据,不收取什么数据。)
  3. 失控的外传数据: 由于Reactor的模式是有数据必须读取,读取后需要有数据返回给客户端,就必定会产生持续的外传数据。在带宽速率不匹配的时候,例如大水管A通过evio中转流向小水管B,累积的待发送数据必然会导致out-of-memory。另一种情况是,某用户突然拔网线,但数据一直产生,也会导致OOM。(期望:完全可控的内存,写操作如果出现阻塞,则反向传导给读操作暂停。)。
  4. 侵入型设计:必须用第三方库提供的数据收发API,完全脱离golang.org/pkg/net,需要较多时间学习API具体使用细则。(期望:有机结合golang.org/pkg/net,简化API学习成本)

特性

针对以上三个问题,gaio选择采用proactor方式实现, 内部只包含三种主要函数:

Read(ctx interface{}, conn net.Conn, buf []byte) error  // 提交一个读取请求
Write(ctx interface{}, conn net.Conn, buf []byte) error  // 提交一个发送请求
WaitIO() (r OpResult, err error) // 等待任意请求完成

用户在原有阻塞模式下使用的net.Conn,如listener.Accept之后的链接可以直接在gaio中使用, 或者你可以先按原有阻塞模型使用net.Conn(例如处理头部,握手),需要时再把net.Conn托管在gaio中使用(注意反向不成立,在gaio中使用后的net.Conn,不能继续按照原有conn.Read/Write方式使用)。

简单来说,gaio的开发模式就是提交请求,等待结果,读写完全受控于业务逻辑。

为什么采用proactor就没有上面reactor模式的问题呢?比如:服务器在进行CPU密集计算时,核心逻辑会被迫延缓提交读取数据的请求,由于socket buffer的写满,并结合TCP的滑动窗口控制,会将压力反向传导给发送端,让其降低或暂停数据的发送,直至计算结束,负载降低后,用户的核心控制逻辑才会再次提交读取请求,让数据继续流入服务器。

关于第二点的独立流问题,例如服务器需要从三处获得数据,才能进行一次合并计算,那么在Proactor模式中,某一处的数据接收到后,就可暂停提交此处链接的读取请求,直到其他两处的数据接收完毕并进行合并计算完成后,再发起下一次从三处读取的读取请求,就不会出现数据无限制流入服务器的情况。 另外,由于gaio采用的是Edge-Triggering模式,暂停读取后,事件循环逻辑也不会无休止的报告有可用数据。

同样,基于Proactor的设计模型,我们并不会持续产生发送数据,并把外传数据堆积到待发送缓冲区内,我们只需要一次处理一点,比如读取n-bytes输,产生m-bytes输出,如果出现超时、阻塞、异常,就能及时停止提交读取请求。

设计难点

  • 串号问题

对于gaio库而言,net.Conn是一个外部对象,这个外部对象由用户产生,则用户可以对这个net.Conn做任何事情,例如,被用户conn.Close()掉,被用户设置各种Deadline,如果我们的epoll/kqueue对于事件的观察是基于net.Conn内部的fd,那么我们就必定会错过close(fd)的事件。因为fd被close后,是无法被epoll_wait/kqueue得知的(file description被内核删除)。

更坏的一个情况是,假设我们当前处理队列中net.Conn的sockfd = 5,库外部用户执行conn.Close()关闭连接,再从listener.Accept()得到新的net.Conn,那极有可能会得到一个拥有相同sockfd=5的文件描述符,此时,恰好我们的gaio正准备处理上一个sockfd=5的可读事件。就会导致读数据的混乱,从一个 conn串到另一个conn。(file descriptor的事件处理缺乏一致性。)

在不牺牲简洁性的前提下,用户在首次提交net.Conn异步调用的时候,对sockfd进行dup()处理,并关闭原有fd(注意TCP会话并不会被关闭。),这样就能得到一个全生命周期一致可控的sockfd,串号问题解决。

  • 资源释放问题

当异步读写请求队列为空时,假如远端已经关闭连接,出现实际的EOF,注意:EOF是通过读取到0个字节,而不是epoll_wait返回EPOLLHUP/EPOLLERR来表示的,对于TCP FIN这种情况, epoll只会告知用户EPOLLIN事件(而非EPOLLHUP)。我们没有任何办法通过预先判断是不是EOF去释放相关资源(例如清空队列,解除绑定,关闭socket fd),除非通过syscall.Read系统调用去真正的读一点数据。然而此刻,读取请求队列为空

如果我们内部开一个buffer在每一次EPOLLIN的时候去预先读取一个字节,并判断返回值是否为0呢?因为无法判断是不是EOF,如果不是,这个缓存必然累积到内部buffer,产生和reactor一样的问题,数据不受控的流入。

如果用Recvmsg,并结合MSG_PEEK标志进行读取呢,我们同样需要在请求队列为空的时候,产生额外的系统调用,性能上非常不划算。

gaio对net.Conn采用的资源释放方式是混合的,在队列存在请求的时候,请求直接进行读写并会返回错误给用户,在用户发现错误后,可以通过Free(net.conn)去立即释放和这个链接有关的资源。其次,初次提交请求的时候,net.Conn会被gaio设置一个Finalizer, 整个系统在没有任何待读写请求,也没有任何外部对象持有此net.Conn的时候,会被GC调用,并释放资源,基于此,gaio内部除了读写请求队列,不会有其他任何地方持有net.Conn对象,仅用对象指针对应。读写请求队列内部持有net.Conn对象的好处是,在有请求的时候,不会被系统异常GC掉net.Conn,net.Conn可以通过不断的异步读写请求保证始终有一处(不管是队列,还是用户需要下一次提交)持有net.Conn,用户不需要单独的数据结构去持有和管理net.Conn.

 runtime.SetFinalizer(pcb.conn, func(c net.Conn) {
     w.gcMutex.Lock()
     w.gc = append(w.gc, c)
     w.gcMutex.Unlock()

     // notify gc processor
     select {
     case w.gcNotify <- struct{}{}:
     default:
     }
 })

相同的释放逻辑在Watcher同样成立,file descriptor的释放问题是整个库的核心问题,Watcher的内部poller的epoll/kqueue fd,事件触发eventfd,以及所有的connection fd,都需要正确无误的释放,在异步环境下要做到不能串号(老请求读到了新fd)。Watcher的释放需要利用对象释放的技巧,如下:

 // Watcher will monitor events and process async-io request(s),
 type Watcher struct {
     // a wrapper for watcher for gc purpose
     *watcher // CORE
 }

 // watcher finalizer for system resources
 wrapper := &Watcher{watcher: w}
 runtime.SetFinalizer(wrapper, func(wrapper *Watcher) {
     wrapper.Close()
 })

因为Watcher内部存在loop goroutine始终持有watcher对象,是无法触发系统GC的,因此外部调用者需要持有一个独立对象(Watcher)去引用内部对象(*watcher),在外部持有对象消失后,GC调用close(chan)去触发goroutine的关闭,并完成资源释放。

  • 小包的上下文切换成本

监听到可读取事件,执行上下文切换到具体goroutine,并执行读取。如果反复执行这种操作,大量的CPU时间会浪费在切换成本自身消耗,在不牺牲代码可读性的前提下,gaio采取平摊法,如果产生大量的小包可读写事件,事件是按批投递到读写任务的。即一个goroutine一次上下文切换会处理一堆可读写事件。

 type pollerEvents []event

基于调度的平摊方法,对于大量小包的TCP连接非常受益,例如,聊天消息,游戏报文(通常很小),网络维护报文。

谢谢!

(全文完)

编辑于 2020-02-22