evio原理解析~有彩蛋
图片拍摄于2022年11月26日 大屋顶
之前分析过go自带的netpoll,以及自建的网络框架gnet。这类框架还有:evio、gev、nbio、cloudwego/netpoll(字节的)
为什么会出现这么多自建框架?
我觉得逃不过三点,
- 自带的netpoll满足不了一些特殊场景。
- 其他实现设计存在局限性,存在优化空间。
- 程序员都喜欢自己造轮子。
另外,这类框架都是基于syscall epoll实现的事件驱动框架。主要区别我觉得在于,
-
对连接conn的管理
-
对读写数据管理
带着这些问题,我打算把这些框架都看一遍。学习里面优秀的设计以及对比他们的不同点,可以的话,做个整体的性能测试。
这几个框架中,evio是最早的开源实现,开源于2017年。
有意思的是,看到几篇文章说evio存在当loopWrite在内核缓冲区满,无法一次写入时,会出现写入数据丢失的bug。
仔细阅读了代码,evio并不存在这个bug。
也不存在是作者后来修复了这个bug,而是evio本身不存在这个bug,下面会说明。
原理解析
根据代码画个简易的evio架构图。
简单解释一下,evio启动的时候可以指定loops个数,即多少个epoll实例。同时可以启动多个监听地址,比如图中监听了两个端口。
程序会把每个Listener fd加入到每个epoll并注册这些fd的读事件。每个epoll会开启一个goroutine等待事件到来。
当客户端发起对应端口连接,程序会根据策略选择一个epoll,并把conn fd 也加入到此epoll并注册读写事件。
当一个conn fd读事件ready,那么对应的epoll会被唤醒,然后执行相应的操作。
以上就是整理的流程,接下来我们来深入一些细节。
在此之前,根据上面所描述的,
- 当一个新的客户端连接到来时,会发生什么?
- 读写数据是如何流动的?
- 同一个epoll里多个fd读写事件ready,程序是如何处理的?
看完下面,再回来回答这三个问题。
代码细节
运行一个简单demo,
我们指定NumLoops的数量是3,然后传入了两个地址。
上面还有两个闭包函数,当服务启动的时候会回调events.Serving函数,然后返回一个Action。
比如当你返回一个Shutdown的action,那么程序就直接退出了。
当有客户端数据到来时,回调events.Data函数,返回out和action,out表示要发送的数据。
最终调用 evio.Serve函数,传入两个地址,启动服务。
我删除了一些无关代码。
Serve 函数里面遍历传入的地址识别协议,执行对应listen操作。udp返回一个PacketConn,而tcp返回一个Listener。最终用自定义的listener统一表示。最终调用serve。
这个函数逻辑:
1.先初始化一个自定义server结构,确定负载均衡算法。
2.回调自定义的serving闭包函数。
3.根据numloops值,创建对应数量的epoll封装在自定义结构loop中。并把每一个listener对应的fd加入到每一个epoll同时注册fd的读事件,
4.遍历loops,每个loop都用一个g执行loopRun函数。
至于loopRun函数,
每个loop都会调用epoll.Wait函数阻塞等待事件到来,参数时一个闭包函数,每一个到达的事件都会回调此闭包执行相应操作。
回到上一步,
注意这里是每个loop都会调用自己的epoll.Wait。当对应的Listener来了一个新客户端连接,所有的epoll都会被“惊醒”,这就是惊群效应。
惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么它就会唤醒等待的所有进程(或者线程)
然后所有的loop都会执行loopAccept函数,
loopAccept做了几件事:
1.确定就绪的fd是哪个listener
2.如果loops大于1,通过策略选择其中一个loop,包装conn fd且设置conn fd为非阻塞(思考下如果让conn fd保持阻塞状态,会影响到什么?)
3.最后把这个conn fd加入到当前epoll并且注册读写事件
因此当一个新的客户端连接到来时,会发生什么?
会产生惊群效应。
那如果是已存在的conn fd的可读事件,会发生惊群效应吗?
不会,因为一个conn fd只会加入到其中一个epoll中。
因为evio创建epoll的时候默认是水平触发LT(level-triggered),当加入的conn fd包含写事件时,如果此时内核写缓冲区空间未满,那么epoll会再次被唤醒。
此时通过f.fdconns[fd]会找到对应的conn,由于连接初始化并未设置opened的直,因此会进入loopOpened函数。
无非是一些简单赋值操作,如果设置了Opened闭包函数,那么回调它。
最后如果out没有可发送的事件,那么就重新把此conn fd修改成读事件。
想象一下,如果在没有可写数据的情况下,加入epoll的conn fd注册含有写事件,那么只要内核写缓存区未满,此epoll会不断被唤醒,我称它是空转。
如果上面你设置了Opened闭包函数且最终action设置了值,那么就还是保持此conn fd的读写事件。
这时候再次唤醒的epoll会执行loopAction,
上面代码很简单。
当client发送数据,epoll被唤醒,执行loopRead。
先读取数据,如果有数据要发送,又会修改conn fd为读写事件。当epoll再次被唤醒,且c.out不为空时,执行loopWrite。
如果一次写完数据,那么说明暂时没有可写的数据了,重新修改conn fd为读事件。
如果一次没写完,那么保留未写完的数据,下次epoll唤醒的时候继续写。
上面提到,一些文章提到,evio存在 loopWrite在内核缓冲区满,无法一次写入时,会出现写入数据丢失的bug。
给的理由是,当内核写缓冲区满了,可数据并未写完。此时另一个conn读事件ready,会执行loopRead。
loopRead有这么一段代码,
那么此时之前未发送完的数据就会被覆盖,导致数据丢失。
但是我仔细看了代码,并不存在这个bug。
因为如果第一次没写完,假设此时同一个epoll下的另一个conn读事件ready,由于out还有未写完的数据,只会执行loopWrite分支, 并不会走到默认分支loopRead,也就不存在写数据被覆盖导致丢失的问题了。
有趣的是,这样的情况会导致空转。因为执行loopWrite逻辑,由于内核写缓冲区已满,导致写不进去数据,会出现syscall.EAGAIN直接返回。又因此时还有可读的数据没读,会不断唤醒epoll。
调用epoll_wait会陷入内核态,所以会导致不断的在用户态和内核态切换。直到写完数据,才能读其他conn数据。
到这里,核心的代码已经分析完了。
Evio存在的问题
惊群效应
串行化
从上面的分析可以看出,如果一个epoll有两个fd可读事件ready,那么第二个fd必须等第一个执行完毕,才开始执行。
换句话说,如果这个闭包函数里有外部依赖调用,第二个就得一直等。
不能在Data函数里用go func吗?
还真不能,要是这样的话又会涉及到数据并发问题,数据会发生错乱。
同一个epoll下的conn是共享数据结构的,如果使用异步,必然又涉及到锁的问题。
数据copy问题
evio采用的是同步处理buffer数据,直接通过syscall读写操作存在copy开销,这是cpu直接参与的。看字节的netpoll使用的zero-copy的技术,后面再看源码。
频繁唤醒epoll
evio会通过不断修改conn fd的事件来唤醒epoll,达到逻辑上的正确性。频繁唤醒的方式并不是很妥,这种方式是存在开销的。
总结
这篇文章到这里就结束了。分析完go自建netpoll "鼻祖" evio,以及它存在的局限性,就可以继续学习后续其他框架的设计了。
转载自:https://juejin.cn/post/7186248752595173434