likes
comments
collection
share

evio原理解析~有彩蛋

作者站长头像
站长
· 阅读数 44

evio原理解析~有彩蛋

图片拍摄于2022年11月26日 大屋顶

之前分析过go自带的netpoll,以及自建的网络框架gnet。这类框架还有:evio、gev、nbio、cloudwego/netpoll(字节的)

为什么会出现这么多自建框架?

我觉得逃不过三点,

  • 自带的netpoll满足不了一些特殊场景。
  • 其他实现设计存在局限性,存在优化空间。
  • 程序员都喜欢自己造轮子。

另外,这类框架都是基于syscall epoll实现的事件驱动框架。主要区别我觉得在于,

  • 对连接conn的管理

  • 对读写数据管理

带着这些问题,我打算把这些框架都看一遍。学习里面优秀的设计以及对比他们的不同点,可以的话,做个整体的性能测试。

这几个框架中,evio是最早的开源实现,开源于2017年。

有意思的是,看到几篇文章说evio存在当loopWrite在内核缓冲区满,无法一次写入时,会出现写入数据丢失的bug。

evio原理解析~有彩蛋

仔细阅读了代码,evio并不存在这个bug。

也不存在是作者后来修复了这个bug,而是evio本身不存在这个bug,下面会说明。

原理解析

根据代码画个简易的evio架构图。

evio原理解析~有彩蛋

简单解释一下,evio启动的时候可以指定loops个数,即多少个epoll实例。同时可以启动多个监听地址,比如图中监听了两个端口。

程序会把每个Listener fd加入到每个epoll并注册这些fd的读事件。每个epoll会开启一个goroutine等待事件到来。

当客户端发起对应端口连接,程序会根据策略选择一个epoll,并把conn fd 也加入到此epoll并注册读写事件。

当一个conn fd读事件ready,那么对应的epoll会被唤醒,然后执行相应的操作。

以上就是整理的流程,接下来我们来深入一些细节。

在此之前,根据上面所描述的,

  • 当一个新的客户端连接到来时,会发生什么?
  • 读写数据是如何流动的?
  • 同一个epoll里多个fd读写事件ready,程序是如何处理的?

看完下面,再回来回答这三个问题。

代码细节

运行一个简单demo,

evio原理解析~有彩蛋

我们指定NumLoops的数量是3,然后传入了两个地址。

上面还有两个闭包函数,当服务启动的时候会回调events.Serving函数,然后返回一个Action。

evio原理解析~有彩蛋

比如当你返回一个Shutdown的action,那么程序就直接退出了。

当有客户端数据到来时,回调events.Data函数,返回out和action,out表示要发送的数据。

最终调用 evio.Serve函数,传入两个地址,启动服务。

evio原理解析~有彩蛋

我删除了一些无关代码。

Serve 函数里面遍历传入的地址识别协议,执行对应listen操作。udp返回一个PacketConn,而tcp返回一个Listener。最终用自定义的listener统一表示。最终调用serve。

evio原理解析~有彩蛋

这个函数逻辑:

1.先初始化一个自定义server结构,确定负载均衡算法。

2.回调自定义的serving闭包函数。

3.根据numloops值,创建对应数量的epoll封装在自定义结构loop中。并把每一个listener对应的fd加入到每一个epoll同时注册fd的读事件,

evio原理解析~有彩蛋

4.遍历loops,每个loop都用一个g执行loopRun函数。

至于loopRun函数,

evio原理解析~有彩蛋

每个loop都会调用epoll.Wait函数阻塞等待事件到来,参数时一个闭包函数,每一个到达的事件都会回调此闭包执行相应操作。

evio原理解析~有彩蛋

回到上一步,

evio原理解析~有彩蛋

注意这里是每个loop都会调用自己的epoll.Wait。当对应的Listener来了一个新客户端连接,所有的epoll都会被“惊醒”,这就是惊群效应。

惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么它就会唤醒等待的所有进程(或者线程)

然后所有的loop都会执行loopAccept函数,

evio原理解析~有彩蛋

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函数。

evio原理解析~有彩蛋

无非是一些简单赋值操作,如果设置了Opened闭包函数,那么回调它。

最后如果out没有可发送的事件,那么就重新把此conn fd修改成读事件。

想象一下,如果在没有可写数据的情况下,加入epoll的conn fd注册含有写事件,那么只要内核写缓存区未满,此epoll会不断被唤醒,我称它是空转。

如果上面你设置了Opened闭包函数且最终action设置了值,那么就还是保持此conn fd的读写事件。

这时候再次唤醒的epoll会执行loopAction,

evio原理解析~有彩蛋

上面代码很简单。

当client发送数据,epoll被唤醒,执行loopRead。

evio原理解析~有彩蛋

先读取数据,如果有数据要发送,又会修改conn fd为读写事件。当epoll再次被唤醒,且c.out不为空时,执行loopWrite。

evio原理解析~有彩蛋

如果一次写完数据,那么说明暂时没有可写的数据了,重新修改conn fd为读事件。

如果一次没写完,那么保留未写完的数据,下次epoll唤醒的时候继续写。

上面提到,一些文章提到,evio存在 loopWrite在内核缓冲区满,无法一次写入时,会出现写入数据丢失的bug。

给的理由是,当内核写缓冲区满了,可数据并未写完。此时另一个conn读事件ready,会执行loopRead。

loopRead有这么一段代码,

evio原理解析~有彩蛋

那么此时之前未发送完的数据就会被覆盖,导致数据丢失。

但是我仔细看了代码,并不存在这个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
评论
请登录