Java NIO - IO多路复用机制详解
IO模型
在Java中,常见的IO模型有4种,
- 同步阻塞IO(Blocking IO): 进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程,操作成功则进程获取到数据;
- 同步非阻塞IO(Non-blocking IO): 默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的
NIO(New IO)库
; - IO多路复用(IO Multiplexing): 也称为异步阻塞IO,Java中的
Selector
和Linux
中的epoll
都是这种模型。这里复用的是指复用一个或几个线程,用一个或一组线程处理多个IO操作,减少系统开销小,不必创建和维护过多的进程/线程; - 异步IO(Asynchronous IO): 即经典的Proactor设计模式,也称为异步非阻塞IO。
IO实现
目前流程的多路复用IO实现主要包括四种: select
、poll
、epoll
、kqueue
。下表是他们的一些重要特性的比较:
IO模型 | 相对性能 | 关键思路 | 操作系统 | JAVA支持情况 |
---|---|---|---|---|
select | 较高 | Reactor | windows/Linux | 支持,Reactor模式(反应器设计模式)。Linux操作系统的 kernels |
2.4内核版本之前,默认使用select;而目前windows下对同步IO的支持,都是select模型 | ||||
poll | 较高 | Reactor | Linux | Linux下的JAVA NIO框架,Linux kernels |
2.6内核版本之前使用poll进行支持。也是使用的Reactor模式 | ||||
epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6内核版本及以后使用epoll进行支持;Linux |
kernels 2.6内核版本之前使用poll进行支持;另外一定注意,由于Linux下没有Windows下的IOCP技术提供真正的 异步IO | ||||
支持,所以Linux下使用epoll模拟异步IO | ||||
kqueue | 高 | Proactor | Linux | 目前JAVA的版本不支持 |
多路复用IO技术最适用的是“高并发”场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好。其他情况下多路复用IO技术发挥不出来它的优势。另一方面,使用JAVA NIO进行功能实现,相对于传统的Socket套接字实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。
同步阻塞IO
核心流程
当应用程序发起 read 系统调用时,在内核数据没有准备好之前,应用程序会一直处于阻塞等待状态,直到内核把数据准备好了返回给应用程序
交互流程
- 服务端进行初始化:新建socket、绑定地址、转为服务端 socket;
- 服务端调用accept,进入阻塞状态,等待客户端连接;
- 客户端新建socket,向服务端发起连接;
- 服务端和客户端通过
TCP
三次握手建立连接; - 服务端继续执行
read
函数,进入阻塞状态,等待客户端发送数据; - 客户端向服务端发送数据;
- 服务端读取数据,执行逻辑处理。
阻塞IO模型
- 应用进程发起 read 系统调用;
- 应用进程阻塞等待数据就绪;
- 数据通过网络传输到达网卡,然后再到内核socket缓冲区,当数据被拷贝到内核
socket
缓冲区时,此时处于就绪状态; - 将数据从内核拷贝到应用程序缓冲区,返回成功。
多线程版本:文中使用的例子是单线程,如果是多线程则在每个 socket 建立连接后新建线程去负责处理该 socket 后续的流程,这样就不会由于单个 socket 阻塞住而影响到其他 socket。
总结
单线程:某个 socket 阻塞,会影响到其他 socket 处理。
多线程:当客户端较多时,会造成资源浪费,全部 socket 中可能每个时刻只有几个就绪。同时,线程的调度、上下文切换乃至它们占用的内存,可能都会成为瓶颈。
同步非阻塞IO
核心流程
当应用程序发起 read 系统调用时,在内核数据没有准备好之前,内核会直接返回错误,应用程序不断轮询内核,直到内核把数据准备好了返回给应用程序。
交互流程
- 服务端调用
accept
,数据未就绪,内核返回-1; - 服务端调用
accept
,数据未就绪,内核返回-1; - 服务端调用
accept
,数据未就绪,内核返回-1; - 客户端新建
socket
,向服务端发起连接; - 服务端调用
accept
,服务端和客户端通过 TCP 三次握手建立连接; - 服务端执行后续逻辑处理。
非阻塞IO模型
总结
提供了非阻塞调用的方式,从操作系统层面解决了阻塞问题。
- 优点: 单个 socket 阻塞,不会影响到其他 socket;
- 缺点: 需要不断的遍历进行系统调用,有一定开销。
SELECT核心流程
- 应用程序首先发起 select 系统调用,传入要监听的文件描述符集合;
- 内核遍历应用程序传入的 fd 集合,如果遍历完一遍后发现没有就绪的 fd 则用户进程会进入阻塞状态,如果有就绪的 fd 则会对就绪的 fd 打标,然后返回;
- 应用程序遍历 fd 集合,找到就绪的 fd,进行相应的事件处理。
/**
* 获取就绪事件
*
* @param nfds 3个监听集合的文件描述符最大值+1
* @param readfds 要监听的可读文件描述符集合
* @param writefds 要监听的可写文件描述符集合
* @param exceptfds 要监听的异常文件描述符集合
* @param timeval 本次调用的超时时间
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
交互流程
- 用户空间发起
select
系统调用,将监听的 fd 集合从用户空间拷贝到内核空间; - 内核遍历 fd 集合,检查数据是否就绪;
- 如果遍历一遍后发现没有 fd 就绪,则会将当前用户进程阻塞,让出 CPU 给其他进程;
- 当客户端将数据发送到服务端,进入内核后,会通过数据库包找到对应的socket。
PS:客户端发送数据到数据进入服务端内核的流程类似下面 epoll
的流程。
socket
检查是否有阻塞等待的进程,如果有则唤醒该进程;- 用户进程恢复运行后,会再遍历 fd 集合进行检查,此时它会检查到某些 fd 已经就绪了,它会给这些 fd 打上标记,然后结束阻塞,返回到用户空间;
- 用户空间知道有事件就绪,遍历 fd 集合,找到就绪的 fd,进行相应的事件处理,例如将数据从内核缓冲区拷贝到应用程序缓冲区;
- 最后执行逻辑处理。
IO多路复用模型
fd_set
fd_set 在 select 的整个调用过程中表达了两种不同的意思。
- 在入参时,
fd_set
表示应用程序要监听哪些 fd;在回参时,fd_set
表示哪些 fd 已经就绪了。 - 应用程序传入的
fd_set
其实是个位图,例如我们要监听fd = 1
、fd = 4
,则传入0000 0101
,也就是 5。 - 这边使用的 long 类型数组来实现位图:1个 long 可以表示64位,则16个long可以表示1024位。
- 当内核处理完毕,将就绪的fd返回时,会将就绪的fd对应的位标记为1,然后覆盖掉入参的 fd_set,所以我们最终返回时的 fd_set 表示的是哪些 fd 是就绪的。
总结
将 socket 是否就绪检查逻辑下沉到操作系统层面,避免大量系统调用。
告诉你有事件就绪,但是没告诉你具体是哪个 FD。
优点:
- 不需要每个 FD 都进行一次系统调用,解决了频繁的用户态内核态切换问题
缺点:
- 单进程监听的 FD 存在限制,默认1024;
- 每次调用需要将 FD 从用户态拷贝到内核态;
- 不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符;
- 入参的3个
fd_set
集合每次调用都需要重置。
POLL核心流程
/**
* 获取就绪事件
*
* @param pollfd 要监听的文件描述符集合
* @param nfds 文件描述符数量
* @param timeout 本次调用的超时时间
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int poll(struct pollfd *fds,
unsigned int nfds,
int timeout);
struct pollfd {
int fd; // 监听的文件描述符
short events; // 监听的事件
short revents; // 就绪的事件
}
poll 函数基本同 select,只是对 select 进行了一些小优化,一个是优化了1024个文件描述符上限,另一个是新定义了 pollfd 数据结构,使用两个不同的变量来表示监听的事件和就绪的事件,这样就不需要像 select 那样每次重置 fd_set 了。
总结
跟 select 基本类似,主要优化了监听1024的限制。
优点
- 不需要每个 FD 都进行一次系统调用,导致频繁的用户态内核态切换
缺点
- 每次需要将 FD 从用户态拷贝到内核态
- 不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符
EPOLL核心流程
- 应用程序调用
epoll_create
,内核会分配一块内存空间,创建一个epoll
,最后将epoll
的 fd 返回,我们后续可以通过这个 fd 来操作epoll
对象 - 应用程序不断调用
epoll_ctl
将我们要监听的 fd 维护到epoll
,内核通过红黑树的结构来高效的维护我们传入的 fd 集合 - 应用程序调用
epoll_wait
来获取就绪事件,内核检查epoll
的就绪列表,如果就绪列表为空则会进入阻塞,否则直接返回就绪的事件。 - 应用程序根据内核返回的就绪事件,进行相应的事件处理
/**
* 创建一个epoll
*
* @param size epoll要监听的文件描述符数量
* @return epoll的文件描述符
*/
int epoll_create(int size);
/**
* 事件注册
*
* @param epfd epoll的文件描述符,epoll_create创建时返回
* @param op 操作类型:新增(1)、删除(2)、更新(3)
* @param fd 本次要操作的文件描述符
* @param epoll_event 需要监听的事件:读事件、写事件等
* @return 如果调用成功返回0, 不成功返回-1
*/
int epoll_ctl(int epfd,
int op,
int fd,
struct epoll_event *event);
/**
* 获取就绪事件
*
* @param epfd epoll的文件描述符,epoll_create创建时返回
* @param events 用于回传就绪的事件
* @param maxevents 每次能处理的最大事件数
* @param timeout 等待I/O事件发生的超时时间,-1相当于阻塞,0相当于非阻塞
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int epoll_wait(int epfd,
struct epoll_event *events,
int maxevents,
int timeout);
交互流程
- 用户空间调用
epoll_create
,内核新建epoll
对象,返回epoll
的 fd,用于后续操作; - 用户空间反复调用
epoll_ctl
将我们要监听的 fd 维护到epoll
,底层通过红黑树来高效的维护 fd 集合; - 用户空间调用 epoll_wait 获取就绪事件,内核检查
epoll
的就绪列表,如果就绪列表为空则会进入阻塞; - 客户端向服务端发送数据,数据通过网络传输到服务端的网卡;
- 网卡通过 DMA 的方式将数据包写入到指定内存中(
ring_buffer
),处理完成后通过中断信号告诉 CPU 有新的数据包到达; - CPU 收到中断信号后,进行响应中断,首先保存当前执行程序的上下文环境,然后调用中断处理程序(网卡驱动程序)进行处理: * 根据数据包的ip和port找到对应的socket,将数据放到socket的接收队列; * 执行 socket 对应的回调函数:将当前 socket 添加到
eventpoll
的就绪列表、唤醒eventpool
等待队列里的用户进程(设置为RUNNING状态) - 用户进程恢复运行后,检查
eventpoll
里的就绪列表不为空,则将就绪事件填充到入参中的events
里,然后返回; - 用户进程收到返回的事件后,执行
events
里的事件处理,例如读事件则将数据从内核缓冲区拷贝到应用程序缓冲区; - 最后执行逻辑处理。
总结
epoll 直接将 fd 集合维护在内核中,通过红黑树来高效管理 fd 集合,同时维护一个就绪列表,当 fd 就绪后会添加到就绪列表中,当应用空间调用 epoll_wait 获取就绪事件时,内核直接判断就绪列表即可知道是否有事件就绪。
优点
- 解决了
select
和poll
的缺点,高效处理高并发下的大量连接,同时有非常优异的性能。
缺点
- 跨平台性不够好,只支持 linux,
macOS
等操作系统不支持 - 相较于
epoll
,select
更轻量可移植性更强 - 在监听连接数和事件较少的场景下,
select
可能更优
LT VS ET
LT:Level- triggered
,水平(条件)触发,默认。epoll_wait
检测到事件后,如果该事件没被处理完毕,后续每次epoll_wait
调用都会返回该事件。
ET:Edge-triggered
,边缘触发。epoll_wait
检测到事件后,只会在当次返回该事件,不管该事件是否被处理完毕。
小结
epoll
和 select
、poll
默认都是 LT 模式,LT 模式会更安全一点,而ET
则是epoll
为了性能开发的一种新模式,LT
模式下内核在返回就绪事件之前都会进行一次额外的判断,如果 fd 量较大,会有一定的性能损耗。
总结
可以看到从最初的同步阻塞IO,到现在主流的epoll
,其实是一个不断演进的过程,就像我们的业务系统一样。
同步阻塞IO的方式实现比较简单,同时在当时可能已经能满足需求了,因此被最早提出来,然后随着不断的发展,在一些场景下,同步阻塞IO逐渐不能满足需求,于是操作系统底层开始优化,提出了非阻塞的模式。类似的,同步非阻塞IO也存在一定的问题,于是就有了后续的IO多路复用。
现在还有一种更牛逼的IO模型也在发展,叫做异步IO,这种模型下,你只需要一次非阻塞的系统调用,后续的事情全部由内核来帮你完成。不过异步IO当前在 linux 下还不够完善,所以当前 linux 的主流还是 epoll。