
nio是java中同步非阻塞io,epoll 是liunx 底层io 多路复用的一种技术,nio实现同步非阻塞是需要epoll底层支持,这篇文章我们就来搞懂epoll和nio的底层原理
计算机网络io的底层
在了解epoll的工作原理前我们需要先了解在linux系统中,一次网络io是怎么去完成的。我们来看下面这张图:

图中有两个需要理解的要点:
- 用户空间的程序(我们自己写的代码)是无法直接访问硬件层面的数据的,需要通过系统调用去将数据读取到内核空间再转到用户空间;
- 计算机执行程序时,会有优先级;一般而言,由硬件产生的信号需要cpu立马做出回应(不然数据可能就丢失),所以它的优先级很高。cpu理应中断掉正在执行的程序,去做出响应;当cpu完成对硬件的响应后,再重新执行用户程序。这种机制叫做中断;
当我们建立一个socket,用户程序通过系统调用向内核空间读取网卡的数据;网卡收到网络数据后,通过回调向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过中断程序去处理数据,然后将数据加载到内核空间,我们的程序再通过系统调用读取到网卡的数据。
上述的流程只是网络io的一个整体流程,我们想要明白为什么会有io多路复用这种机制出现还要进一步了解服务器建立socket的过程。当服务器建立一个socket,操作系统会创建一个由文件系统管理的socket对象。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员,等待队列存放所有需要等待该socket事件的进程。其伪代码如下:
1 | //创建socket |
recv是个阻塞方法,当程序运行到recv时,它会一直等待,直到接收到数据才往下执行。当recv阻塞,是不会消耗cpu资源的,而我们网络io往往会有很多个socket,那么有没有什么办法,用一个进程去等待多个recv,也就是io多路复用?如下图所示,当进程c执行到recv方法等待网络数据时会被放入等待队列,cpu不会去执行进程c,也就是进程c不会占用cpu资源,当网卡有数据到来的时候网卡就会通过中断来将进程A唤醒,并挂到工作队列中让cpu运行它。

图中的fd是文件描述符,一个socket就是一个文件,socket句柄就是一个文件描述符。
那这个怎么和传统的BIO联系起来呢,我们看bio建立socket的代码:
1 | { |
传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据。也就是说图中的进程如果进入了等待队列(recv方法阻塞),socket.read()也会跟着阻塞,直到有数据过来,它相当于直接调用操作系统recv方法。
select和epoll
图中的机制还存在一个问题,前文提到过如果socket中有数据过来,网卡会给一个中断给到cpu,那么中断程序又是怎么去找到对应的fd的,这里面的工作流程是怎么样的?我们来看select是怎么做的:
数组fds存放着所有需要监视的socket,然后调用select,如果fds中的所有socket都没有数据,select会阻塞;当有一个socket接收到数据,发起一个中断,select返回,唤醒进程,将进程从所有socket的等待队列中移除,遍历fds,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。该方法实现了一个进程监听多个socket,而不是傻傻的等recv返回(就像bio那样),但是这种方式有致命的缺点:
- 每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。
- 进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次
为了解决上述缺点,于是有了epoll。在epoll中,内核维护一个“就绪列表”,引用收到数据的socket,避免了进程被唤醒后需要遍历才能找到socket,于是原来的模型就被改成了这样:

evenpoll 未 epoll 创建的对象,它内部维护了“就绪列表”;内核会将eventpoll添加到socket的等待队列中,当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。当有socket就绪,发起中断,中断会将对应的socket引用存入rdlist中,并唤醒进程,进程只需要找rdlist就能快速处理就绪的socket;另:evenpoll通过红黑树保存监视的socket,而rdlist也并非直接引用socket,而是通过epitem间接引用,红黑树的节点也是epitem对象,图中简化了模型。
nio 多路复用
既然操作系统提供给我们这么好的机制,自然不能白白浪费,我们应用程序也想一个线程监听多个socket,谁有数据就起一个线程让它接收数据干活,干完活就扔到线程池里面去;为了更好的设计一套多路复用的java框架,我们需要知道epoll的api:
- epoll_create:当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象
- epoll_ctl:epoll 注册并监听事件的函数;
- epoll_wait:等待文件描述符epfd上的事件。
我们来看NIO是如何进行IO的:
1 | public static void main(String[] args) throws IOException, InterruptedException { |
然后查看一下</font>Selector<font style="color:rgb(77, 77, 77);">类linux系统下的的实现

它包括了 PollSelectorImpl,EPollSelectorImpl,SelectedSelectionKeySetSelector实现类,我们知道io多路复用在linux 中有Select,Poll Epoll 三种实现,自然就对应了三个类,我们重点关注EPollSelectorImpl的源码,在NIO使用示例中 Selector是通过Selector.open()创建的,我们重点看看如何创建的这个Selector:
1 | public class EPollSelectorProvider |
我们看到在EPollSelectorImpl构造器中调用了EPoll.create() 这个便是epoll的api。我们也可以顺便看看EPoll类的源码:
1 | class EPoll { |
通过idea 可以看到sun.nio.ch.EPollSelectorImpl#doSelect调用了EPoll.wait:

EPoll.ctl在多处调用:

主要调用方法是sun.nio.ch.EPollSelectorImpl#processUpdateQueue:

这个方法在Selector.Select中被调用

大概意思就是选择一个准备好的通道。
通过上述的源码,我们发现NIO其实就是对linux多路复用的一个封装。