Netty源码分析——EPOLL之epollWait和唤醒
前言
上一篇咱们一起看了eventfd
和timerfd
,主要就是给这篇做铺垫的,这一章主要是讲解EpollEventLoop
的run
方法中的select
过程,这个select
指的是我们在最早文章中说的Reactor
线程干的三件事之一的select
。
我们上篇中的eventfd
主要作为唤醒epollWait
的手段,而timerfd
因为其阻塞直到超时
的特性,主要用来做超时控制。可能有人会问了,你别欺负我不懂EPOLL
,EPOLL
自带超时控制,参数里可以指定timeout
的,怎么还需要借助timerfd
来做超时控制呢?
这个问题提前解答一下,timerfd
支持纳秒级别,而epoll_wait
的参数是毫秒级别。所以这里使用timerfd
来控制超时,而epoll_wait
参数不是0(没有事件也立即返回)就是-1(表示永远等待)。
源码
先看看EpollEventLoop
的初始化方法:
1 | FileDescriptor epollFd = null; |
这里我们可以看到,epoll会监听timerfd
和eventfd
的IO事件,这里很重要,后面我们在底层的系统调用(C语言的代码)中还会看见这两个文件描述符。
继续看run
方法,还是万年不变的直接就是个死循环,直到我们shutdown
。一开始会计算一个策略,表示最开始执行什么,可以是SELECT
或者BUSY_SELECT
。规则就是,没有任务就阻塞select
(注意我们这里说的虽然是select
,但是最底层都是epoll_wait
,select
表示等待事件),有就非阻塞select
,select
到事件就处理,select
不到就继续往下走。
我们看一下两种select
方式:
1 | case SelectStrategy.BUSY_WAIT: |
第一种叫epollBusyWait
,第二种叫epollWait
,注意这里底层是两种不同的调用,我们先看epollWait
,因为epollBusyWait
只是通过底层指令优化了轮训。
epollWait
方法追进去是Native.epollWait(epollFd, events, timerFd, delaySeconds, delayNanos)
:
1 | int ready = epollWait0(epollFd.intValue(), events.memoryAddress(), events.length(), timerFd.intValue(), |
直接调用native
方法,最终会执行到C语言文件中的netty_epoll_native_epollWait0
函数。全局搜索即可找到,看一下实现:
1 | struct epoll_event *ev = (struct epoll_event*) (intptr_t) address; |
这里又上下两个分支,第一个分支比较简单,是立即返回(epoll_wait
的最后一个参数是0)。我们主要看的是阻塞的方式,在这里我们使用了timerfd
作为超时控制。
我在EpollEventLoop
初始化的时候就说了,我们除了初始化epoll
的文件描述符之外,还初始化了两个文件描述符,分别是eventfd
和timerfd
,而且这两个文件描述符都被交给了epoll
来监听IO。我们先来看timerfd
是如何控制超时的。
设置好超时时间,我们进行了一次result = epoll_wait(efd, ev, len, -1);
。这里我们分两个大章节看一下这个部分的逻辑。
C语言源码部分
从这里开始就进入整个控制epoll
的核心了。
select
到事件分为几种情况:
select
到的事件中,全都是socket
的IO。result > 0
,且当result = 1
的时候result == 1 && ev[0].data.fd == timerFd
返回的是false
。因为都是socket
的IO,所以ev
中不存在timerfd
。select
到的事件中,只有timerfd
的IO,我们刚刚说了,timerfd
到了超时时间,回写一个超时次数到timerfd
的文件中,所以这种场景其实就是超时了。result == 1 && ev[0].data.fd == timerFd
这时候就返回true
,因为只有timerfd
超时写了一个数据进去,被epoll
监听到了,那么返回的fd当然就是timerfd
。select
到的事件中,有socket
的IO,也有timerfd
的IO事件。result == 1 && ev[0].data.fd == timerFd
这时候就返回false
。因为result
至少是2。
第一种,全部都是socket
事件,就算result
是1(只有一个socket
事件),ev[0].data.fd == timerFd
返回的也是false
,所以根据上面的代码,就直接return
了。
第二种,只有timerfd
的事件,也就是超时了,这时候进入下面的代码块:
1 | uint64_t timerFireCount; |
由于我们已经超时了,所以read
会立即返回。注意这里我们要把timerfd
中的内容读走,因为我们是ET模式,这样我们才可以收到下次的通知,这里忘记ET工作方式的同学请看一下我以前的关于ET和LT工作方式的文章。
这里我们其实就知道timerfd
是如何控制epoll
的超时的了。我们用epoll
监听timerfd
,然后给timerfd
设置超时时间,这个超时时间其实就是我们希望epoll
阻塞select
的阻塞时间。到了时间,就算epoll
没有select
到其他socket
的IO事件,至少也会select
到timerfd
的IO事件,也就是说:result = epoll_wait(efd, ev, len, -1);
最多只会阻塞超时时间那么长,然后就会被唤醒(并且返回timerfd
的描述符)!
这里Netty巧妙利用了timerfd
的超时就写入的特点,用epoll
监听timerfd
来时间超时控制。为什么用timerfd
上面说过了,因为timerfd
可以控制到纳秒级别,而epoll_wait
调用只能控制到毫秒级别。
第三种情况,既有socket
事件,且还混有timerfd
事件。这里跟第一种一样,会直接返回。
等等,我们刚刚不是说了,需要把timerfd
中的事件读取走,如果不读取走,将来就收不到新的通知了,那这里直接返回,下次timerfd
就算IO了,epoll
监听不到怎么办?如果你能想到这个问题,说明是用心思考了,这个问题会在java部分的源码中找到答案。
java源码部分
我们看一下processReady
方法,我们成功返回了result且不为0,就会进入到这里,我们上面说了,第三种情况可能是socket
的IO事件和timerfd
的事件混在一起,看看怎么处理:
1 | for (int i = 0; i < ready; i ++) { |
谜底解开,我们在上述第三种情况中,也需要把timerfd
中的数据读取走,以此来控制我们下次还可以收到timerfd
的IO。但是由于我们收到的事件中,混杂了timerfd
和socket
的IO事件,所以我们在Reactor
线程中进行timerfd
的读操作。说来说去就是一句话:不管怎么样也要把timerfd
中超时的时候写入的数据读走!
我们在代码中还看到了这里:
1 | if (fd == eventFd.intValue()) { |
这里涉及到唤醒流程,我们下一节讲。
再往下就是处理socket
的IO事件了,这里我们可以看到我们处理了写和读事件,除此之外还处理了EPOLLRDHUP
这个事件。对端正常关闭的时候会触发这个事件(还会触发EPOLLIN
)。这个EPOLLRDHUP
我理解里算半个读事件,在Netty里我们后面也会说到,处理这个事件的时候,如果channel
是active
的,就会处理读。
事件处理我们以后再说,这里我们再看看eventfd
的作用。
用eventfd来控制epoll的唤醒
eventfd
同样在初始化的时候就被交给epoll
去监听了。用法我们也说过了,就是一方写,另一方久可以读。这里很多同学可能在看了上面就猜到了,一说读写,我们立即就要反应过来,读写都会被epoll
监听到,又因为我们的eventfd
是被epoll
监听的,那么我们如果向eventfd
里写数据,不就可以中断epoll_wait
了么,因为epoll_wait
这时候至少返回eventfd
!
我们猜到这一步,就直接看看wakeup
方法,因为我们之前在Reactor
机制中说,如果有任务进来,我们需要唤醒阻塞select
,目的就是防止我们新的任务一直被阻塞没有机会执行,wakeup
:
1 | if (!inEventLoop && WAKEN_UP_UPDATER.compareAndSet(this, 0, 1)) { |
果然就是向eventfd
中写入一个数据来唤醒epoll_wait
。那么写入以后有个问题,我们要时刻记住,Netty的epoll
默认使用的是ET,我们写入数据后,为了下次还能收到eventfd
的IO通知,必须把旧数据读走,这时候我们结合上一小节的待解决的问题:
1 | if (fd == eventFd.intValue()) { |
不用多说了吧,跟timerfd
相同的套路,把数据读取走为了能获取新的数据。
总结
Netty的epoll
和nio
都是依托于Reactor模型,当然kqueue
也是一样,万年不变的Reactor
线程三个任务,包括唤醒逻辑,只不过epoll
是依托于eventfd
和timerfd
,而nio
是通过selector
的wakeup
。
总的来说两个底层fd的作用:
eventfd
:为了能够直接唤醒阻塞select
。timerfd
:为了能够定期唤醒阻塞select
。
至此整个epoll
的select
就看完了。
关于使用边缘触发
这里我在网上查阅资料的时候,看到几个非常不错的问答以及需要注意的点,这里列出来,为我们理解边缘触发和后面解析读取/写入操作都很有好处,先来一个问答:
Q:在使用边沿触发时,我需要对一个文件描述符持续地read/write
直到出现EAGAIN
吗?
A:从epoll_wait()
收到一个事件则表明这个文件描述符已经准备好做对应的I/O操作了。直到下一次read/write
出现EAGAIN
之前,你必须认为它是已经准备好了的。至于什么时候和怎样使用这个文件描述符就完全看你自己了。对于基于数据包类型的文件描述符,比如UDP
和canonical
模式下的terminal
,检测read/write
的I/O空间是否用尽的唯一方法就是持续地read/write
直到出现EAGAIN
。对于基于数据流的文件描述符,比如pipe
,FIFO
,TCP
,还可以用检测向目标文件描述符发送/接收数据的总量的方法可以检测read/write
的I/O空间是否用尽。
这里的QA我们主要提取一个内容即可:对于数据包类型的文件描述符,我们需要不断地read/write
直到出现EAGAIN
,实际上Netty也是这样做的,数据包类型的文件描述符对应EpollDatagramChannel
。对于数据类类型的文件描述符,可以用检测向目标文件描述符发送/接收数据的总量的方法可以检测read/write
的I/O空间是否用尽,Netty中数据流类型的文件描述符对应AbstractEpollStreamChannel
这个类的子类,Netty也是使用读取的Buffer
是否读取满来判断是否读完的。
另外说一个边缘触发的饥饿问题:
使用边沿触发模式时的饥饿问题,如果某个socket源源不断地收到非常多的数据,那么在试图读取完所有数据的过程中,有可能会造成其他的socket
得不到处理,从而造成饥饿(这个问题不只针对epoll)。解决的办法是为每个已经准备好的描述符维护一个队列,这样程序就可以知道哪些描述符已经准备好了但是还在轮询等待。当然,简单的方法是使用水平触发模式。