云深知处——IO多路复用(select、epoll)
序言
从高并发IO的底层原理一文中,已经看到了5大IO模型在底层是怎样运行的。紧接着,我们聚焦IO多路复用,看下其实现原理。
内核怎么知道接收的网络数据是属于哪个
socket?
socket数据包格式是一个五元组(源ip,源端口,协议,目的ip,目的端口),一般通过目的ip和目的端口就可以识别出来接收到的网络数据属于哪个socket。如果目的ip,目的端口相同呢?其实多个客户端与同一个服务端建立了连接,这个时候内核就会有多个socket,并且为它们分配多个fd文件描述符。它们收到网络数据后无法通过目的端口来直接匹配socket,还需要再通过源ip和端口来确定属于哪个socket。
内核在收到数据时,在网卡上网卡程序知道数据包的源ip+端口,目标ip+端口,进而判定数据是属于哪个socket。所以,文件描述符和数据包的源ip+端口,目标ip+端口有个映射关系。
内核怎么在一个网卡上监听多个
socket?
IO多路复用,内核负责轮询所有socket,当某个socket有数据到达了,就通知用户进程。在Linux里边有3个系统调用,select、poll、epoll;在Windows里常见的有2个,select、poll。
学习高并发,
epoll是基础。epoll作为Linux下高性能网络服务器的必备技术至关重要,Java NIO、Nginx、Redis、SkyNet和大部分游戏服务器都使用到这一多路复用技术。
select、poll底层系统调用
先来看看select和poll。
select
Linux、Windows内核都提供了select操作,可以把1024个文件描述符的IO事件轮询,简化为一次轮询,轮询发生在内核空间。
连接以文件描述符的形式在内核中都有注册,用户程序可以通过select操作去轮询这些文件连接的IO事件(读、写、异常)。
下面这段代码就是select操作的使用方法,先准备一个数组fds存放着所有需要监视(轮询)的socket。然后调用select,如果fds中的所有socket都没有数据,select会阻塞(进程就会加入socket等待队列),直到有一个socket接收到数据(有IO事件发生),select就会返回,唤醒进程。返回之后还有一次轮询,用户可以遍历fds数组,挨个判断每个文件描述符是否有数据过来,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。
总结下主逻辑,伪代码如下,
1
2
3
4
5
6
7
8
9
int fds[] = "存放需要监听的socket"
while (1) {
int n = select(..., fds, ...) //fds加入到socket阻塞队列,select返回后,唤醒进程
for (int i=0; i < fds.count; i++) {
if (FD_ISSET(fds[i], ...)) {
//fds[i]的数据处理
}
}
}
代码核心步骤,大致分两步,
- 第一步,把所有需要轮询的
socket fd放到数组里, - 第二步,通过
select系统调用去查询数组中的文件描述符是否有数据可读,如果返回,就表示有一些fd连接有数据过来了,就去判断是哪个socket收到了数据。
select是poll的基础,看个例子,假如程序同时监视socket1、socket2和socket3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个Socket的等待队列中。
进程A在查询这3个socket连接的时候,就会加入到这3个连接的等待队列里。 现在假设连接2收到了数据,系统调用就会唤醒等待队列里的进程A,
被唤醒的进程A会被移到操作系统的工作队列,这时3个socket的等待队列都会移除进程A,假如进程A在查询1024个连接,这1024个连接的等待队列都会移除进程A。
对进程A来说,如果有一个连接的数据过来了,它会从多个等待队列中移除。
另外,在进程A中,开始执行后不知道是哪个socket来了数据,所以还会遍历所有socket。
最后,在处理完成任务后,会继续进行阻塞监听,还会加入到所有socket的等待队列里。
总结下,对于调用了
select的进程A来说,
A存在于多个socket的等待队列中;- 当某个
socket被写入数据时,A也被唤醒并从多个socket的等待队列中移除后加入内核的工作队列;- 但是此时
A并不知道是哪个socket被写入了数据,所以只能遍历所有socket;- 在
A处理完任务后移出内核的工作队列,但是此时却需要遍历所有socket并加入它们的等待队列中。
现在可以看到,select系统调用的性能问题,select有多次列表遍历,
- 第一次,进程加入到所有
socket的等待队列,需要遍历所有socket; - 第二次,当进程被某个
socket唤醒后,只知道至少有一个socket接收了数据,但不知道是哪一个,在用户空间里还需要遍历一次socket列表,才知道就绪的socket; - 另外,调用
select,把文件描述符传到内核,也有开销,主要还是遍历操作。
正是因为遍历操作开销大,出于效率的考量,才规定了
select的最大监视数量,默认只能监视1024个socket。
poll
因为这个原因,出现了poll。1997年,poll作为select的替代者,最大的区别就是,poll不再限制socket数量。
下面这段代码就是poll操作的使用方法,整体和select类似,
poll的内部实现基本跟select一样,区别在于它们底层组织fd[]的数据结构不太一样,从而实现了poll的最大文件句柄数量限制去除了。
poll描述fd集合的方式不同,poll使用pollfd结构(这个文件描述符数据结构稍微复杂点),而不是select的fd_set结构,其他的都差不多,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。
1
2
3
SYNOPSIS
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中参数如下,
pollfd *fds,是C语言的列表(可以理解为数组)nfds,fd的数量timeout,轮询的超时时限
关键在于,pollfd结构(在Java里边也会涉及到)有3个成员,fd(文件描述符)、events(事件,表示要监测的fd的事件)、revents(返回的事件),详细描述如下,
1
2
3
4
5
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 用户指定的、需要监听的事件(输入参数)
short revents; // 内核返回的、实际发生的事件(输出参数)
};
fd:每一个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。events:表示要告诉操作系统需要监测fd的事件(输入、输出、错误),每一个事件有多个取值。revents:revents域是文件描述符的操作结果事件,内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。
select就是一个文件描述符的集合,没有事件相关的字段。
poll定义了一系列的事件常量,
| 事件 | 描述 | 是否可作为输入(events) | 是否可作为输出(revents) |
|---|---|---|---|
| POLLIN | 数据可读(包括普通数据&优先数据) | 是 | 是 |
| POLLOUT | 数据可写(普通数据&优先数据) | 是 | 是 |
| POLLRDNORM | 普通数据可读 | 是 | 是 |
| POLLRDBAND | 优先级带数据可读(linux不支持) | 是 | 是 |
| POLLPRI | 高优先级数据可读,比如TCP带外数据 | 是 | 是 |
| POLLWRNORM | 普通数据可写 | 是 | 是 |
| POLLWRBAND | 优先级带数据可写 | 是 | 是 |
| POLLRDHUP | TCP连接被对端关闭,或者关闭了写操作,由GNU引入 | 是 | 是 |
| POPPHUP | 挂起 | 否 | 是 |
| POLLERR | 错误 | 否 | 是 |
| POLLNVAL | 文件描述符没有打开 | 否 | 是 |
什么情况下数据可读?换句话说,
socket读就绪条件(读事件)有哪些?
以下4种情况,当对socket进行读操作,都不阻塞,而是返回,不同情况返回值不一样,通通都可以理解为发生了读事件。
- 内核缓冲区的字节数,大于等于接收缓冲区的低水位标记(可配置的常量)。缓冲区低水位的值默认为
1,只要收到1个字节,就认为socket发生了读事件; - 连接都是全双工模式,连接的读半部关闭(对方已经把连接关了,但我们还没关,对方不会写数据过来了),这时对套接字做
poll,它不会阻塞,也会返回,返回是0,认为也发生了读事件,只不过事件的值是0; - 如果是一个监听套接字,且有新的连接过来了(有没有完成建立的连接),这个连接的
accept操作通常也不会阻塞,也发生读事件; - 当套接字发生了错误,对它进行读操作也不会阻塞,返回
-1。
详细说明
- 缓冲区数据达到低水位标记
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓存区低水位标记
SO_RCVLOWAT。 - 对于
TCP和UDP套接字而言,缓冲区低水位的值默认为1。那就意味着,默认情况下,只要缓冲区中有数据,那就是可读的。我们可以通过使用SO_RCVLOWAT套接字选项(参见setsockopt函数)来设置该套接字的低水位大小。此种描述符就绪(可读)的情况下,当我们使用read/recv等对该套接字执行读操作的时候,套接字不会阻塞,而是成功返回一个大于0的值(即可读数据的大小)。
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓存区低水位标记
- 连接读半关闭
- 该连接的读半部关闭(也就是接收了
FIN的TCP连接)。对这样的套接字的读操作,将不会阻塞,而是返回0(也就是EOF)。
- 该连接的读半部关闭(也就是接收了
- 监听套接字有已完成连接
- 该套接字是一个
listen的监听套接字,并且目前已经完成的连接数不为0。对这样的套接字进行accept操作通常不会阻塞。
- 该套接字是一个
- 套接字存在待处理错误
- 有一个错误套接字待处理。对这样的套接字的读操作将不阻塞并返回
-1(也就是返回了一个错误),同时把errno设置成确切的错误条件。这些待处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
- 有一个错误套接字待处理。对这样的套接字的读操作将不阻塞并返回
什么时候连接可写?换句话说,
socket写就绪条件(写事件)有哪些?
- 发送缓冲区有空间,就认为是可写,比如查询一个套接字,检查它的发送缓冲区,有可写的空间,就会发生写事件,判断条件:空闲空间大于等于低水位标记;
- 你主动把
socket关了,它还是可以发生写事件,只不过返回不是正常值; - 套接字发生错误,去查,也会发生写事件,只不过返回
-1。
详细说明
- 发送缓冲区空闲空间达到低水位
socket内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。- 对于
TCP和UDP而言,这个低水位SO_SNDLOWAT的值默认为2048,而套接字默认的发送缓冲区大小是8k,这就意味着一般一个套接字连接成功后,就是处于可写状态的。我们可以通过SO_SNDLOWAT套接字选项(参见setsockopt函数)来设置这个低水位。此种情况下,我们设置该套接字为非阻塞,对该套接字进行写操作(如write、send等),将不阻塞,并返回一个正值(例如由传输层接受的字节数,即发送的数据大小)。
- 连接写半关闭
- 该连接的写半部关闭(主动发送
FIN包的TCP连接)。对这样的套接字的写操作将会产生SIGPIPE信号。所以我们的网络程序基本都要自定义处理SIGPIPE信号。因为SIGPIPE信号的默认处理方式是程序退出。
- 该连接的写半部关闭(主动发送
- 非阻塞
connect完成- 使用非阻塞的
connect套接字已建立连接,或者connect已经以失败告终。即connect有结果了。
- 使用非阻塞的
- 套接字存在待处理错误
- 有一个错误的套接字待处理。对这样的套接字的写操作将不阻塞并返回
-1(也就是返回了一个错误),同时把errno设置成确切的错误条件。这些待处理的错误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
- 有一个错误的套接字待处理。对这样的套接字的写操作将不阻塞并返回
socket可读、可写、发生异常,大概分为下面这些条件。
| 条件 | 可读吗? | 可写吗? | 异常吗? |
|---|---|---|---|
| 有数据可读 | ● | ||
| 关闭连接的读一半 | ● | ||
| 给监听套接口准备好新连接 | ● | ||
| 有可用于写的空间 | ● | ||
| 关闭连接的写一半 | ● | ||
| 待处理错误 | ● | ● | |
TCP带外数据 | ● |
poll系统调用,就是增加了一系列的事件,这些事件是select调用没有的,poll相较于select,没有做出多少性能改进。
poll系统调用的本质
select和poll系统调用的本质一样;poll的机制与select在本质上没有多大差别,每次调用时,都需要把fd集合从用户态拷贝到内核态;- 二者管理多个描述符也是进行轮询,根据描述符的状态进行处理。
epoll底层系统调用
epoll原理
epoll是在select、poll出现很多年后才被发明的,是select和poll的增强版本。
select为什么低效?
一次select实际上做了两件事,第一个是把进程添加到socket等待队列,第二个是进行事件的阻塞等待,等到事件来了就返回,返回处理完,又来一次select,维护等待队列,然后阻塞。
一次select调用,将维护等待队列和阻塞等待两个步骤合二为一,紧密耦合
实际上,大部分情况下,一次系统调用所要监视的socket连接数相对固定,并不需要每次都修改(每次都把进程加入到那么多socket的等待队列里,反复地移除加入)。
epoll将一个系统调用分为两个,epoll_ctl和epoll_wait,
- 第一个去维护等待队列,进程对
socket感兴趣,就加入socket等待队列,就不动了,每次调用都去找一次; - 另一个专门做事件的监听,阻塞进程。
epoll通过以下措施来改进效率,
- 功能分离:进程到等待队列,进程阻塞;
- 引入了就绪列表
rdlist
下面这段代码就是epoll操作的使用方法,先用epoll_create创建一个epoll对象epollfd,再通过epoll_ctl将需要监视的socket添加到epollfd的专用监听列表中,最后调用epoll_wait等待数据,返回rdlist列表中的就绪socket。
总结下主逻辑,伪代码如下,
1
2
3
4
5
6
7
8
9
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //第一步:将所有需要监听的 socket 添加到 epfd 中等待队列
while (1) {
int n = epoll_wait(...); //第二步:阻塞进程,等待事件
for("接收到数据的socket"){
//处理
}
}
epoll的三个方法
epoll_create创建一个专用的文件描述符,eventpoll对象(也就是程序中epfd所代表的对象),会有自己的等待队列;epoll_ctl,添加待监控的socket,eventpoll对象有一个监听队列,通过epoll_ctl系统调用把需要监视的socket连接添加到监听队列;epoll_wait,阻塞等待,用的很频繁,每次事件来了,就会返回,处理完了就再等待,不断轮询。当调用epoll_wait轮询操作的时候,就有点像select,进程就会进入eventpoll对象的等待队列。事件来了,进程就会移入CPU工作队列。
把等待队列移开,移到专用的文件描述符里的等待队列。这个优化之后,就不需要每次阻塞的时候,进行文件描述符反复的传输和遍历。
看下图,某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(专有文件描述符),和socket一样,它也有等待队列。
epoll第一步,epoll_create,epoll创建eventpoll对象
可以用epoll_ctl添加(或删除)所有需要监听的socket到监听队列,等待队列用来放进程,监听队列用来放文件描述符。
另外一个优化措施,引入了一个就绪队列,用来放有事件发生的
socket连接,当轮询的时候,只要就绪列表有事件,就会返回就绪列表,不需要去监听列表做全局的轮询遍历,以空间换时间。
当socket收到数据后,中断程序会操作eventpoll的就绪队列rdlist,而不是直接操作读取数据的进程(比如进程A)。当socket2和socket3收到数据后,中断程序让这两个socket进入rdlist。
例子,假设进程A通过epoll来监控3个socket连接,内部结构大概如下,
epoll第三步,epoll_wait,epoll的等待列表
假设计算机中正在运行进程A和进程B,三个socket连接会加入到epoll的监听队列,在某时刻进程A运行到了epoll_wait语句,如果rdlist非空则返回,如果rdlist为空,内核会将进程A放入文件描述符eventpoll的等待队列中,阻塞进程。
如果某一个连接发生了事件(socket接收到数据),中断程序会做两个工作,
- 第一,
socket会进入rdlist; - 第二,会唤醒
eventpoll等待队列的进程,进程A会进入到工作队列,再次进入运行状态。
因为rdlist的存在,进程A可以知道哪些socket发生了变化。
总结
epoll三大步骤如下,
create会创建一个文件描述符,以及一个内部结构,不止是一个普通的文件描述符,还对应一个专用的内存结构,这个内存结构包含了一个红黑树,来维护要监听的socket。
- 为什么
epoll和select/poll性能不一样?能监听的socket文件描述符更多,不止1024个,性能更好,效率更高,因为底层用了红黑树结构,替代了select的数组结构。- 如果哪个
socket需要监听,就通过ctl系统调用把它加入到红黑树的监听队列。- 还有个就绪队列
rdlist,如果有事件发生,那么它的所有连接都会放到列表里边,然后返回给用户进程。
Linux epoll API
epoll_create
1
2
#include
int epoll_create(int size) //创建一个epoll的句柄。自从linux2.6.8之后,size参数是被忽略的。
需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,如果查看/proc/进程id/fd/,能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
1
2
3
4
5
6
7
8
cd /proc/进程id/fd/
ls -l
lrwx--- 1 root root 64 Nov 21 09:44 133 -> /dev/sda1
lrwx--- 1 root root 64 Nov 21 09:44 134 -> /dev/sdb1
lrwx--- 1 root root 64 Nov 21 09:44 136 -> /dev/sdb1
lrwx--- 1 root root 64 Nov 21 09:44 137 -> socket:[22460]
lrwx--- 1 root root 64 Nov 21 09:44 138 -> socket:[7326842]
lrwx--- 1 root root 64 Nov 21 09:44 139 -> socket:[7341066]
socket:后面的一串数字是什么呢?其实是该socket的inode号。
epoll_ctl
epoll的事件注册函数,先注册要监听的事件类型。
1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
其中参数如下,
- 第一个参数是
epoll_create()的返回值epollfd句柄epfd。 - 第二个参数表示动作,用三个宏来表示:
EPOLL_CTL_ADD: 注册新的fd到epfd中;EPOLL_CTL_MOD: 修改已经注册的fd的监听事件;EPOLL_CTL_DEL: 从epfd中删除一个fd;
- 第三个参数是需要监听的
fd,比如说socket A、socket B、socket C - 第四个参数是告诉内核需要监听什么事 ,使用
epoll_event结构
epoll监听事件(struct epoll_event)结构如下,
1
2
3
4
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events字段用来标记需要监听的事件类型,data字段用来存储用户自定义数据(可以理解为附加数据,想放什么都可以,自己放自己用),一般会关联对应的文件描述符。
epoll的events事件类型,和poll的事件类型类似,events可以是以下几个宏的集合,
EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭);EPOLLOUT:表示对应的文件描述符可以写;EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);EPOLLERR:表示对应的文件描述符发生错误;EPOLLHUP:表示对应的文件描述符被挂断;EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
epoll_event结构data成员变量,是一个union(共用体)类型的变量,类型定义如下,
1
2
3
4
5
6
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
union如果该结构对象属于动态存储类型,其成员具有不确定、灵活的初始值。
epoll_ctl的events参数是一个数组,调用之前创建好,当内核发生了epoll事件后,会把事件复制到这个数组里边,所以我们需要提前分配好内存。
epoll_wait
等待epoll监控的事件中已经发生的事件,类似于select()调用。
1
2
#include
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
其中参数如下,
- 第二个参数
events用来从内核得到事件的集合。epoll将会把发生的事件赋值到events数组中(events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存)。 - 第三个参数
maxevents告知内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size。 - 第四个参数
timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
eventpoll对象
eventpoll对象把连接和事件做了封装,eventpoll的rbr是一颗红黑树,存放所有的socket;rdlist是一个双向链表,存放已经发生IO事件的socket;等待队列为poll_wait,进程A放在poll_wait里。
解读:
Linux内核中epoll的核心数据结构,各部分分工清晰,
- 红黑树用来组织管理所有需要监听的文件描述符,保证增删改查的效率都较高;
- 双向链表用来存放就绪的、已经发生
IO事件的描述符,让epoll_wait可以直接高效返回就绪事件;- 等待队列用来阻塞等待
IO事件,让调用epoll_wait的进程进入休眠,当有事件就绪时内核会唤醒进程。
红黑树的大小远远大于1024,rdlist是双向列表,列表节点不是连接,也不是事件,而是专用的结构epitem。
红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。eventpoll的rbr等待队列是一颗红黑树,监听所有socket的IO事件。
正因为epoll采用了红黑树的结构,所以能监听的连接数可以远远大于1024个。本地都可以监听100w的连接,这是select操作不可想象的。
Linux的ET高速模式与Netty的高速Selector
水平触发和边缘触发
之前API的event事件类型说过,epoll有EPOLLLT和EPOLLET两种触发模式,
LT(Level Trigger,水平触发):只要socket缓冲区还有可读写数据,每次调用epoll_wait都会返回该事件,编程更简单,且不容易丢数据,是默认触发模式;ET(Edge Trigger,边缘触发):仅当socket状态从无事件变为有事件时才会触发一次,需要把缓冲区数据一次性处理完,减少了事件重复触发的次数,效率更高,因此被称为高速模式。
ET和LT,来自电子的概念,
ET,边沿触发,当电平出现变化的时候才触发事件;LT,水平触发,只要存在高电平就一直触发事件。
形象地解释下上图的趋势,
- 当电平出现变化的时候,才触发,就是
ET边缘触发,下图从0变到1,就触发ET; LT水平触发,只要存在高电平,就会不断触发。
举个例子,感受下
epoll里边的LT、ET。
先看下LT,假如用户发了100B的数据,进入到内核缓冲区,如果第一次读取,读走了50B,内核缓冲区还有50B,等到用epoll_wait来做事件查询的时候,如果是LT水平触发,还能够查到一个可读事件,这就是水平触发,只要有数据,就表示高电平,能够发生可读事件。
执行epoll_wait时LT,LT模式下,只要这个文件描述符还有数据可读,每次epoll_wait都会返回它的事件,提醒用户程序去操作。
再来看下ET,如果第一次读取,读走了50B,内核缓冲区还有50B,这个时候没新的数据过来,再次用epoll_wait来做事件查询的时候,就没有查出来可读事件,哪怕是内核缓冲区有数据,要查出可读事件,得等到下次新数据到来才行。
执行epoll_wait时ET,ET模式下,在它检测到有IO事件时,通过epoll_wait调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让errno返回EAGAIN为止,否则下次的epoll_wait不会返回余下的数据,会丢掉事件。
现在讲的epoll都是非阻塞的,如果是阻塞式的,低电平,如果不是非阻塞的,只要有数据,就一直读,在最后一次阻塞,没数据了才阻塞。实际上高并发的模式下,都是非阻塞的AIO。
比较下ET和LT,
- 边缘触发,不管事件是不是处理完了,不管缓冲区的数据是不是读取光了,数据到来的时候,就触发一次;
- 水平触发,只要缓冲区存在数据,只要调用
wait操作,就会返回一个事件。
ET和LT的区别在于触发事件的条件不同,LT比较符合编程思维(有满足条件的就触发),ET触发的条件更苛刻一些(仅在发生变化时才触发),对使用者的要求也更高,理论效率更高。
总之,ET的效率高于LT模式,因为产生的事件数更少,可以减少内核往应用层空间复制数据的次数。在进行高性能网络编程的时候,往往都是选择非阻塞IO+ET触发模式,这种模式下可以做到想读数据的时候就读,不想读就不读。
Java中的selector触发模式
Java NIO默认水平触发,默认不是最高效率的模式。
值得一提的是,Java NIO的selector会根据操作系统不同采用不同的实现。
- 在
Linux 2.6以后,NIO的selector版本则是EPollSelectorProvider,EPollSelectorProvider底层使用的是epoll,采用的是水平触发模式; - 在
MacOS下是KQueueSelectorProvider,KQueueSelectorProvider底层使用了kqueue来进行IO多路复用; - 而
Netty中提供的额外的EpollEventLoop(Netty自己写了一个EpollEventLoop)则采用了边缘触发。
Netty的Selector
虽然JDK自身提供了selector的epoll实现,Netty仍实现了自己的epoll版本,根据Netty开发者在StackOverflow的回答,主要原因有两个,
- 支持更多
socket option,比如TCP_CORK和SO_REUSEPORT; - 使用了边缘触发(
ET)模式。
如何使用Netty自己的Linux epoll实现
在
Linux系统,怎么使用Netty高性能的selector? 因为不是Netty核心依赖,所以需要做专门配置。
第一步,首先需要在项目中添加netty-transport-native-epoll依赖,因为这个不在Netty核心包中,
1
2
3
4
5
6
7
8
9
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
<version>${project.version}</version>
<classifier>linux-x86_64</classifier>
</dependency>
...
</dependencies>
第二步,启动类里边使用反应器组、通道、传输通道的时候,全都使用Epoll开头的类,将代码中的NioEvnetLoop、NioSocketChannel、NioServerSocketChannel等替换为Epoll开头的类即可。
1
2
3
4
NioEventLoopGroup --> EpollEventLoopGroup
NioEventLoop --> EpollEventLoop
NioServerSocketChannel --> EpollServerSocketChannel
NioSocketChannel --> EpollSocketChannel
只要引入了依赖,并且使用了Netty自己实现的反应器和通道,就可以使用Netty高性能的epoll模式。详情参考如何使用netty自己的Linux epoll实现。
IO事件底层原理
不同语言有不同IO事件的表达,比如IO事件在Java和C语言之间的不同表达,IO事件在NIO、JNI之间的翻译。
Java的IO事件类型定义在选择键里边,4个事件,是用位运算来定义的。可以监听四种不同类型的事件(这四种事件用SelectionKey的四个常量来表示),
SelectionKey.OP_CONNECT连接就绪(连接事件)SelectionKey.OP_ACCEPT接收就绪(新连接接收事件)SelectionKey.OP_READ读就绪(通道读取缓冲区可读)SelectionKey.OP_WRITE写就绪(通道写入缓冲区可读)
SelectionKey中的事件常量如下,
1
2
3
4
5
6
7
8
//读操作符,左移位后的整型值为1
public static final int OP_READ = 1 << 0;
//写操作符,左移位后的整型值为4
public static final int OP_WRITE = 1 << 2;
//连接操作符,左移位后的整型值为8
public static final int OP_CONNECT = 1 << 3;
//接收操作符,左移位后的整型值为16
public static final int OP_ACCEPT = 1 << 4;
各常量的含义说明,
OP_CONNECT:表示某个Channel成功连接到服务器。OP_ACCEPT:表示一个Server Socket Channel准备好接收新进入的连接。OP_READ:一个有数据可读的通道可以说是读就绪。OP_WRITE:表示channel等待写数据,或者说可以写入数据,就是通道的写入缓冲区大于低水位SO_SNDLOWAT的值(默认为2048),而套接字默认的发送缓冲区大小是8k,所以总是可写的。
在操作系统,或者C语言里,不一样,比如poll系统调用有自己定义的事件,读和写是可以对应过来的,Windows上NIO默认使用的就是poll。
Java里边怎么把两种事件对应上的呢?
有一个通用的类Net.java,里边定义了对应的事件,Net.java通过JNI加载不同平台的poll事件的定义值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static final short POLLIN;
public static final short POLLOUT;
public static final short POLLERR;
public static final short POLLHUP;
public static final short POLLNVAL;
public static final short POLLCONN;
static native short pollinValue();
static native short polloutValue();
static native short pollerrValue();
static native short pollhupValue();
static native short pollnvalValue();
static native short pollconnValue();
static {
IOUtil.load();
initIDs();
POLLIN = pollinValue();
POLLOUT = polloutValue();
POLLERR = pollerrValue();
POLLHUP = pollhupValue();
POLLNVAL = pollnvalValue();
POLLCONN = pollconnValue();
}
定义的常量怎么对应到操作系统的常量?
定义了一组本地方法,JNI调用,之后启动加载的时候,调用JNI方法,把本地C语言里边的常量值加载到内存里边去。
Java里边定义的事件常量,就是C语言里边poll事件类型的值。
Windows里边Net.c,C语言的代码,就返回的是Windows系统当中的poll事件类型的值。
Net.c中的事件的值,这些也对应C中的select轮询事件, Windows的Net.c中值如下,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
JNIEXPORT jshort JNICALL
Java_sun_nio_ch_Net_pollinValue(JNIEnv *env, jclass this)
{
return (jshort)POLLIN;
}
JNIEXPORT jshort JNICALL
Java_sun_nio_ch_Net_polloutValue(JNIEnv *env, jclass this)
{
return (jshort)POLLOUT;
}
JNIEXPORT jshort JNICALL
Java_sun_nio_ch_Net_pollerrValue(JNIEnv *env, jclass this)
{
return (jshort)POLLERR;
}
// ...
Java的NIO事件(4个),在C语言里边就对应好几个事件,这些事件中的翻译工作,是在下面两个方法里边完成的。
NIO事件到JNI事件的翻译(两次事件翻译工作),
- 注册时的翻译:从
NIO事件到JNI事件(SocketChannelImpl#translateInterestOps) - 查询时的翻译:从
JNI事件到NIO事件(SocketChannelImpl#translateReadyOps)
看下Java事件翻译为本地事件,注册时,正向翻译,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
SocketChannelImpl#translateInterestOps
Translates an interest operation set into a native poll event set
*/
public int translateInterestOps(int ops) {
int newOps = 0;
if ((ops & SelectionKey.OP_READ) != 0)
newOps |= Net.POLLIN;
if ((ops & SelectionKey.OP_WRITE) != 0)
newOps |= Net.POLLOUT;
if ((ops & SelectionKey.OP_CONNECT) != 0)
newOps |= Net.POLLCONN;
return newOps;
}
本地事件翻译为Java事件,就绪时,反向翻译,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
SocketChannelImpl#translateReadyOps
Translates native poll revent ops into a ready operation ops
*/
public boolean translateReadyOps(int ops, int initialOps, SelectionKeyImpl ski) {
int intOps = ski.nioInterestOps();
int oldOps = ski.nioReadyOps();
int newOps = initialOps;
// ...
boolean connected = isConnected();
if (((ops & Net.POLLIN) != 0) &&
((intOps & SelectionKey.OP_READ) != 0) && connected)
newOps |= SelectionKey.OP_READ;
// ...
if (((ops & Net.POLLOUT) != 0) && ((intOps & SelectionKey.OP_WRITE) != 0) && connected)
newOps |= SelectionKey.OP_WRITE;
ski.nioReadyOps(newOps);
return (newOps & ~oldOps) != 0;
}
对epoll来说,系统调用的事件也是类似过程,这里不做展开。















