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

    谢谢!

    (全文完)

  • 相关阅读:
    Benelux Algorithm Programming Contest 2016 Preliminary K. Translators’ Dinner(思路)
    Benelux Algorithm Programming Contest 2016 Preliminary Target Practice
    Benelux Algorithm Programming Contest 2016 Preliminary I. Rock Band
    Benelux Algorithm Programming Contest 2016 Preliminary A. Block Game
    ICPC Northeastern European Regional Contest 2019 Apprentice Learning Trajectory
    ICPC Northeastern European Regional Contest 2019 Key Storage
    2018 ACM ICPC Asia Regional
    2018 ACM ICPC Asia Regional
    Mybatis入库出现异常后,如何捕捉异常
    优雅停止 SpringBoot 服务,拒绝 kill -9 暴力停止
  • 原文地址:https://www.cnblogs.com/scotth/p/12625582.html
Copyright © 2011-2022 走看看