gaio小记 : 思考proactor模式 网络模型(转)

转自 gaio小记

gaio项目

[问题的提出] (https://github.com/golang/go/issues/15735​)
此链接是集中讨论这个问题的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上下文切换成本会高于数据的处理成本,

例如消息转发场景   ![](https://www.zhihu.com/equation?tex=Cost_%7Bcs%7D+%3E+Cost_%7Bpayload%7D),
这种情况是完全没有必要进行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这样一种数据处理模型,存在以下几个问题:

//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学习成本

特性

针对以上三个问题(选型 1,2,3),
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输出,如果出现超时、阻塞、异常,就能及时停止提交读取请求。

设计难点

1.串号问题

对于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,串号问题解决。

2.资源释放问题

当异步读写请求队列为空时,假如远端已经关闭连接,出现实际的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的关闭,并完成资源释放。

3.小包的上下文切换成本

监听到可读取事件,执行上下文切换到具体goroutine,并执行读取。
如果反复执行这种操作,大量的CPU时间会浪费在切换成本自身消耗,


在不牺牲代码可读性的前提下,gaio采取平摊法,如果产生大量的小包可读写事件,事件是按批投递到读写任务的。
即一个goroutine一次上下文切换会处理一堆可读写事件。

type pollerEvents []event

基于调度的平摊方法,对于大量小包的TCP连接非常受益,

例如,聊天消息,游戏报文(通常很小),网络维护报文。

谢谢!

(全文完)

posted @ 2020-04-03 11:53  scott_h  阅读(404)  评论(0编辑  收藏  举报