Java NIO - IO多路复用机制详解

2023年2月27日
大约 13 分钟

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中的SelectorLinux中的epoll都是这种模型。这里复用的是指复用一个或几个线程,用一个或一组线程处理多个IO操作,减少系统开销小,不必创建和维护过多的进程/线程;
  • 异步IO(Asynchronous IO): 即经典的Proactor设计模式,也称为异步非阻塞IO。

IO实现

目前流程的多路复用IO实现主要包括四种: selectpollepollkqueue。下表是他们的一些重要特性的比较:

IO模型相对性能关键思路操作系统JAVA支持情况
select较高Reactorwindows/Linux支持,Reactor模式(反应器设计模式)。Linux操作系统的 kernels 2.4内核版本之前,默认使用select;而目前windows下对同步IO的支持,都是select模型
poll较高ReactorLinuxLinux下的JAVA NIO框架,Linux kernels 2.6内核版本之前使用poll进行支持。也是使用的Reactor模式
epollReactor/ProactorLinuxLinux kernels 2.6内核版本及以后使用epoll进行支持;Linux kernels 2.6内核版本之前使用poll进行支持;另外一定注意,由于Linux下没有Windows下的IOCP技术提供真正的 异步IO 支持,所以Linux下使用epoll模拟异步IO
kqueueProactorLinux目前JAVA的版本不支持

多路复用IO技术最适用的是“高并发”场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好。其他情况下多路复用IO技术发挥不出来它的优势。另一方面,使用JAVA NIO进行功能实现,相对于传统的Socket套接字实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。

同步阻塞IO

核心流程

当应用程序发起 read 系统调用时,在内核数据没有准备好之前,应用程序会一直处于阻塞等待状态,直到内核把数据准备好了返回给应用程序

交互流程

  1. 服务端进行初始化:新建socket、绑定地址、转为服务端 socket;
  2. 服务端调用accept,进入阻塞状态,等待客户端连接;
  3. 客户端新建socket,向服务端发起连接;
  4. 服务端和客户端通过TCP三次握手建立连接;
  5. 服务端继续执行read函数,进入阻塞状态,等待客户端发送数据;
  6. 客户端向服务端发送数据;
  7. 服务端读取数据,执行逻辑处理。

阻塞IO模型

同步阻塞IO模型

  1. 应用进程发起 read 系统调用;
  2. 应用进程阻塞等待数据就绪;
  3. 数据通过网络传输到达网卡,然后再到内核socket缓冲区,当数据被拷贝到内核 socket 缓冲区时,此时处于就绪状态;
  4. 将数据从内核拷贝到应用程序缓冲区,返回成功。

多线程版本:文中使用的例子是单线程,如果是多线程则在每个 socket 建立连接后新建线程去负责处理该 socket 后续的流程,这样就不会由于单个 socket 阻塞住而影响到其他 socket。

总结

单线程:某个 socket 阻塞,会影响到其他 socket 处理。

多线程:当客户端较多时,会造成资源浪费,全部 socket 中可能每个时刻只有几个就绪。同时,线程的调度、上下文切换乃至它们占用的内存,可能都会成为瓶颈。

同步非阻塞IO

核心流程

当应用程序发起 read 系统调用时,在内核数据没有准备好之前,内核会直接返回错误,应用程序不断轮询内核,直到内核把数据准备好了返回给应用程序。

交互流程

  1. 服务端调用accept,数据未就绪,内核返回-1;
  2. 服务端调用accept,数据未就绪,内核返回-1;
  3. 服务端调用accept,数据未就绪,内核返回-1;
  4. 客户端新建socket,向服务端发起连接;
  5. 服务端调用accept,服务端和客户端通过 TCP 三次握手建立连接;
  6. 服务端执行后续逻辑处理。

非阻塞IO模型

同步非阻塞IO模型

总结

提供了非阻塞调用的方式,从操作系统层面解决了阻塞问题。

  • 优点: 单个 socket 阻塞,不会影响到其他 socket;
  • 缺点: 需要不断的遍历进行系统调用,有一定开销。

SELECT核心流程

  1. 应用程序首先发起 select 系统调用,传入要监听的文件描述符集合;
  2. 内核遍历应用程序传入的 fd 集合,如果遍历完一遍后发现没有就绪的 fd 则用户进程会进入阻塞状态,如果有就绪的 fd 则会对就绪的 fd 打标,然后返回;
  3. 应用程序遍历 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);

交互流程

  1. 用户空间发起 select 系统调用,将监听的 fd 集合从用户空间拷贝到内核空间;
  2. 内核遍历 fd 集合,检查数据是否就绪;
  3. 如果遍历一遍后发现没有 fd 就绪,则会将当前用户进程阻塞,让出 CPU 给其他进程;
  4. 当客户端将数据发送到服务端,进入内核后,会通过数据库包找到对应的socket。

PS:客户端发送数据到数据进入服务端内核的流程类似下面 epoll 的流程。

  1. socket 检查是否有阻塞等待的进程,如果有则唤醒该进程;
  2. 用户进程恢复运行后,会再遍历 fd 集合进行检查,此时它会检查到某些 fd 已经就绪了,它会给这些 fd 打上标记,然后结束阻塞,返回到用户空间;
  3. 用户空间知道有事件就绪,遍历 fd 集合,找到就绪的 fd,进行相应的事件处理,例如将数据从内核缓冲区拷贝到应用程序缓冲区;
  4. 最后执行逻辑处理。

IO多路复用模型

同步非阻塞IO模型

fd_set

fd_set 在 select 的整个调用过程中表达了两种不同的意思。

  • 在入参时,fd_set表示应用程序要监听哪些 fd;在回参时,fd_set表示哪些 fd 已经就绪了。
  • 应用程序传入的fd_set其实是个位图,例如我们要监听 fd = 1fd = 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核心流程

  1. 应用程序调用 epoll_create,内核会分配一块内存空间,创建一个 epoll,最后将 epoll 的 fd 返回,我们后续可以通过这个 fd 来操作 epoll 对象
  2. 应用程序不断调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,内核通过红黑树的结构来高效的维护我们传入的 fd 集合
  3. 应用程序调用 epoll_wait 来获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞,否则直接返回就绪的事件。
  4. 应用程序根据内核返回的就绪事件,进行相应的事件处理
/**
* 创建一个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);

交互流程

  1. 用户空间调用 epoll_create ,内核新建 epoll 对象,返回 epoll 的 fd,用于后续操作;
  2. 用户空间反复调用 epoll_ctl 将我们要监听的 fd 维护到 epoll,底层通过红黑树来高效的维护 fd 集合;
  3. 用户空间调用 epoll_wait 获取就绪事件,内核检查 epoll 的就绪列表,如果就绪列表为空则会进入阻塞;
  4. 客户端向服务端发送数据,数据通过网络传输到服务端的网卡;
  5. 网卡通过 DMA 的方式将数据包写入到指定内存中(ring_buffer),处理完成后通过中断信号告诉 CPU 有新的数据包到达;
  6. CPU 收到中断信号后,进行响应中断,首先保存当前执行程序的上下文环境,然后调用中断处理程序(网卡驱动程序)进行处理:
    • 根据数据包的ip和port找到对应的socket,将数据放到socket的接收队列;
    • 执行 socket 对应的回调函数:将当前 socket 添加到 eventpoll 的就绪列表、唤醒 eventpool 等待队列里的用户进程(设置为RUNNING状态)
  7. 用户进程恢复运行后,检查 eventpoll 里的就绪列表不为空,则将就绪事件填充到入参中的 events 里,然后返回;
  8. 用户进程收到返回的事件后,执行 events 里的事件处理,例如读事件则将数据从内核缓冲区拷贝到应用程序缓冲区;
  9. 最后执行逻辑处理。

总结

epoll 直接将 fd 集合维护在内核中,通过红黑树来高效管理 fd 集合,同时维护一个就绪列表,当 fd 就绪后会添加到就绪列表中,当应用空间调用 epoll_wait 获取就绪事件时,内核直接判断就绪列表即可知道是否有事件就绪。

优点

  • 解决了 selectpoll 的缺点,高效处理高并发下的大量连接,同时有非常优异的性能。

缺点

  • 跨平台性不够好,只支持 linux,macOS 等操作系统不支持
  • 相较于 epollselect 更轻量可移植性更强
  • 在监听连接数和事件较少的场景下,select 可能更优

LT VS ET

LT:Level-triggered,水平(条件)触发,默认。epoll_wait检测到事件后,如果该事件没被处理完毕,后续每次epoll_wait调用都会返回该事件。

ET:Edge-triggered,边缘触发。epoll_wait检测到事件后,只会在当次返回该事件,不管该事件是否被处理完毕。

小结

epollselectpoll 默认都是 LT 模式,LT 模式会更安全一点,而ET则是epoll为了性能开发的一种新模式,LT模式下内核在返回就绪事件之前都会进行一次额外的判断,如果 fd 量较大,会有一定的性能损耗。

总结

可以看到从最初的同步阻塞IO,到现在主流的epoll,其实是一个不断演进的过程,就像我们的业务系统一样。

同步阻塞IO的方式实现比较简单,同时在当时可能已经能满足需求了,因此被最早提出来,然后随着不断的发展,在一些场景下,同步阻塞IO逐渐不能满足需求,于是操作系统底层开始优化,提出了非阻塞的模式。类似的,同步非阻塞IO也存在一定的问题,于是就有了后续的IO多路复用。

现在还有一种更牛逼的IO模型也在发展,叫做异步IO,这种模型下,你只需要一次非阻塞的系统调用,后续的事情全部由内核来帮你完成。不过异步IO当前在 linux 下还不够完善,所以当前 linux 的主流还是 epoll。

引用资料

  • https://blog.csdn.net/weixin_44046799/article/details/127082826