epoll高效运行的原理

nio非阻塞牛逼在哪里?

要解答这个问题首先要清楚bio的痛点在哪里。

假如我们的服务使用bio模型,服务器启动之后需要一条线程一直监听端口上是否有连接请求发过来,如果没有请求,这条线程就一直等着;

等了好久好久终于来了一个请求连接,连接建立成功后,又要开始等业务请求,由于网络慢或者其他原因又等了一千年;

终于拿到了请求,电光火石间处理完拿到响应数据,准备响应出去,这时候发现由于不明原因,一千五百年之前的一条响应还没有发送出去,占据了内核缓冲区,这时候害得等,等前一条响应发出去才能将本次的响应写入缓冲区。。。沧海桑田

当然这里可以使用多线程优化,但是没有解决根本问题,那就是线程等太久的问题。

这时候nio模式横空出世,牛逼在不用等。nio在linux平台基于epoll实现的

接下来的疑问

1、epoll是基于事件的,那么有哪些事件,事件由谁来触发?

2、jdk nio是怎么和epoll实现对接的?

带着这两个疑问,查阅了网上一些文章,大概能解答以上两点疑惑

链接:彻底搞懂epoll高效运行的原理

epoll是一种I/O事件通知机制,是linux 内核实现IO多路复用的一个实现。

IO多路复用是指,在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作。

事件

可读事件,当文件描述符关联的内核读缓冲区可读,则触发可读事件。(可读:内核缓冲区非空,有数据可以读取)

可写事件,当文件描述符关联的内核写缓冲区可写,则触发可写事件。(可写:内核缓冲区不满,有空闲空间可以写入)

epoll的通俗解释是:一种当文件描述符的内核缓冲区非空时发出可读信号通知、当写缓冲区不满时发出可写信号通知的机制

epoll的API

epoll的核心是3个API,核心数据结构是:1个红黑树和1个链表

epoll

1. int epoll_create(int size);
创建一个epoll对象,返回对象的句柄,后面两个操作都以句柄为核心。

参数size用来表示要监听的fd数量的最大值,之后版本的Linux已弃用该参数。

1
Selector selector = Selector.open();

java nio中,执行以上代码,就会在底层调用epoll_create方法创建一个epoll对象

2.int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

epoll的事件注册接口,负责将被监听的描述符添加到红黑树或从红黑树中删除或者对监听事件进行修改。
参数 epfd 表示epoll对象句柄;
参数 op 表示动作,用三个宏来表示:

1
2
3
EPOLL_CTL_ADD //注册新的fd到epfd中;
EPOLL_CTL_MOD //修改已经注册的fd的监听事件;
EPOLL_CTL_DEL //从epfd中删除一个fd;

参数 fd 是需要监听的fd

参数 event 表示此次注册的事件,struct epoll_event结构如下:

data域是唯一能给出描述符信息的字段,所以在调用epoll_ctl加入一个需要监测的描述符时,一定要在此域写入描述符相关信息;

events域是bit mask,描述一组epoll事件,在epoll_ctl调用中解释为:描述符所期望的epoll事件,可多选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
表示某个fd上某事件被触发了
**/
struct epoll_event {
__uint32_t events; // 在被监测的文件描述符上实际发生的事件。
epoll_data_t data;
};

typedef union epoll_data {
void *ptr; // 指向用户自定义数据
int fd; //注册的文件描述符
__uint32_t u32; //32-bit integer
__uint64_t u64; //64-bit integer
} epoll_data_t;

常用的epoll事件

1
2
3
4
5
6
7
8
EPOLLIN //表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLOUT //表示对应的文件描述符可以写
EPOLLET //将 EPOLL设为边缘触发
EPOLLONESHOT //第一次进行通知,之后不再监测
EPOLLPRI //由带外数据触发
EPOLLERR //描述符产生错误时触发,默认检测事件
EPOLLHUP //本端描述符产生一个挂断事件,默认监测事件
EPOLLRDHUP //对端描述符产生一个挂断事件

在java nio中执行事件注册的代码如下:

1
SelectionKey key = channel.register(selector,Selectionkey.OP_READ);

channel就是fd在jdk中的包装对象,selector可以认为是epoll对象,这一句的含义是将一个fd注册到epoll对象上,且监听的事件是OP_READ

3、int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

功能:阻塞等待事件的发生,返回事件数目,并将触发的事件写入参数events数组中

参数:events: 用来记录被触发的events,其大小应该和maxevents一致

​ maxevents: 返回的events的最大个数

处于ready状态的那些FD会被复制进ready list中,epoll_wait 用于向用户进程返回这个list,events和maxevents两个参数描述一个由用户分配的struct epoll event数组,调用返回时,内核将ready list复制到这个数组中,并将实际复制的个数作为返回值。注意,如果ready list比maxevents长,则只能复制前maxevents个成员;反之,则能够完全复制ready list。

参数timeout描述在函数调用中阻塞时间上限,单位是ms:

  • timeout = -1表示调用将一直阻塞,直到有文件描述符进入ready状态或者捕获到信号才返回;

  • timeout = 0用于非阻塞检测是否有描述符处于ready状态,不管结果怎么样,调用都立即返回;

  • timeout > 0表示调用将最多持续timeout时间,如果期间有检测对象变为ready状态或者捕获到信号则返回,否则直到超时。

java nio 中,Selector类有以下方法

1
2
3
int select() //阻塞方法,阻塞到至少有一个通道在注册的事件上就绪。 timeout = -1
int select(long timeout) //超时返回的阻塞方法 timeout > 0
int selectNow() //非阻塞方法,不管是否有通道就绪,立即返回。 timeout = 0

这句就是查找就绪fd,底层将调用epoll_wait方法。

epoll的两种触发方式

epoll监控多个文件描述符的I/O事件。epoll支持边缘触发(edge trigger,ET)或水平触发(level trigger,LT),通过epoll_wait等待I/O事件,如果当前没有可用的事件则阻塞调用线程。

select和poll只支持LT工作模式,epoll的默认的工作模式是LT模式。

1.水平触发的时机

对于读操作,只要缓冲不为空,LT模式返回读就绪。对于写操作,只要缓冲区还不满,LT模式会返回写就绪。

当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。

2.边缘触发的时机

对于读操作,以下三种情况会触发读就绪事件:

  • 当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候;

  • 当有新数据到达时,即缓冲区中的待读数据变多的时候;

  • 当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时(设置监听读就绪时);

响应的对于写操作,以下情况会触发写就绪事件:

  • 当缓冲区由不可写变为可写时。
  • 当有旧数据被发送走,即缓冲区中的内容变少的时候。
  • 当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。

当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,就是说只会通知一次,直到该文件描述符上触发第二次可读写事件时才会再次通知。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

举例1:

读缓冲区刚开始是空的读缓冲区写入2KB数据,水平触发和边缘触发模式此时都会发出可读信号收到信号通知后,读取了1KB的数据,读缓冲区还剩余1KB数据,水平触发会再次进行通知,而边缘触发不会再进行通知

举例2:(以脉冲的高低电平为例)

水平触发:0为无数据,1为有数据。缓冲区有数据则一直为1,则一直触发。边缘触发发:0为无数据,1为有数据,只要在0变到1的上升沿才触发。

JDK在Linux已经默认使用epoll方式,但是JDK的epoll采用的是水平触发,而Netty重新实现了epoll机制,采用边缘触发方式,netty epoll transport 暴露了更多的nio没有的配置参数,如 TCP_CORK, SO_REUSEADDR等等;另外像Nginx也采用边缘触发。

epoll与select、poll的对比

1. 用户态将文件描述符传入内核的方式

  • select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。

  • poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听。

  • epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。

2. 内核态检测文件描述符读写状态的方式

  • select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
  • poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历。
  • epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。

3. 找到就绪的文件描述符并传递给用户态的方式

  • select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
  • poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
  • epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。这里返回的文件描述符是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。

4. 重复监听的处理方式

  • select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
  • poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
  • epoll:无需重新构建红黑树,直接沿用已存在的即可。

epoll更高效的原因

select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标注位来存放,所以select会受到最大连接数的限制,而poll不会。

select、poll、epoll虽然都会返回就绪的文件描述符数量。但是select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可。

select、poll都需要将有关文件描述符的数据结构拷贝进内核,最后再拷贝出来。而epoll创建的有关文件描述符的数据结构本身就存于内核态中,系统调用返回时利用mmap()文件映射内存加速与内核空间的消息传递:即epoll使用mmap减少复制开销。

select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。

epoll的边缘触发模式效率高,系统不会充斥大量不关心的就绪文件描述符虽然epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。