转自 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上下文切换成本会高于数据的处理成本, |
| |
| 例如消息转发场景 , |
| 这种情况是完全没有必要进行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 |
| } |
| 由于不得不及时接收(不接收会阻塞所有socket接收), |
| 会导致数据接收部分过分争夺计算资源(调度),或内存资源,缺乏根据实际服务器负载状况的反向传导机制。 |
| |
| 期望:可控的CPU资源分配,在高业务负载的时候也调整接收速度。 |
| 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连接非常受益, |
| |
| 例如,聊天消息,游戏报文(通常很小),网络维护报文。 |
谢谢!
(全文完)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)