• 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连接非常受益,
    
    例如,聊天消息,游戏报文(通常很小),网络维护报文。
    

    谢谢!

    (全文完)

  • 相关阅读:
    (十一)设置关闭多核cpu的核
    (十)修改开发板主频
    (九)How to use the audio gadget driver
    (8)全志A64查看寄存器
    内存溢出问题配置
    百度数据库优化经验
    如何让sql运行的更快
    百度性能优化
    spring ioc原理
    JNDI
  • 原文地址:https://www.cnblogs.com/scotth/p/12625582.html
Copyright © 2020-2023  润新知