前言

在 Java 中,有三种 IO 模型: BIO, NIO, AIO。介绍这三种 IO 模型之前,需要介绍一下同步,异步与阻塞,非阻塞的概念,然后再从 Java 和 Linux OS 的角度去分析 BIO, NIO 和 AIO。

同步和异步

同步

同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。

异步

异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。

区别

同步与异步最大的区别就是被调用方执行方式返回时机,同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方。

阻塞和非阻塞

阻塞

阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。

非阻塞

非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

同步、异步和阻塞、非阻塞的区别

阻塞和同步不是一回事,同步,异步与阻塞,非阻塞针对的对象是不一样的,阻塞,非阻塞是说的调用者,同步,异步说的是被调用者

BIO、NIO、AIO概览

  • BIO(Blocking I/O):BIO 也就是传统的同步阻塞 IO 模型,对应 Java.io 包,它提供了很多 IO 功能,比如输入输出流,对文件进行操作。在网络编程( Socket 通信)中也同样进行 IO 操作。
  • NIO(New I/O): NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。
  • AIO: AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。

Linux的5种 I/O模型

上面简单介绍了 Java 中的三种 IO 模型,三种模型提供的与 IO 有关的 API,在文件处理时,底层实际上是依赖操作系统层面的 IO 操作实现的,比如在 Linux 2.6 以后,Java 中的 NIO 和 AIO 都是通过 epoll 来实现的,关于 epoll 等概念后面也会阐述。

而实际上在 Linux(Unix) 操作系统中,共有五种 IO 模型,分别是:阻塞 IO 模型非阻塞 IO 模型IO 复用模型信号驱动 IO 模型以及异步 IO 模型,而4种都是同步的,只有最后一种是异步的。

阻塞IO模型 - BIO

一个输入操作通常包括两个不同的阶段:

  • 等待数据准备好
  • 从内核向进程复制数据

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达,当所等待分组到达时,它被复制到内核中的某个缓冲区,第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

从上图可以看出,应用进程通过系统调用 recvfrom 去接收数据,而由于内核数据没有准备好,应用进程就会阻塞,直到内核准备好数据并将其从内核复制到应用进程的缓冲区中或者发生错误才返回。最常见的错误就是系统调用被信号中断。进程从调用 recvfrom 开始到它返回的整段时间内是被阻塞的。

Linux下的阻塞式 I/O 模型就对应了 Java下的 BIO 模型,BIO 的底层实现是调用操作系统的 API 去执行的,也就是调用操作系统的 Socket 套接字。

非阻塞式I/O模型 - NIO

应用进程通过系统调用 recvfrom 不断的去和内核交互,直到内核数据报准备好,而如果内核无数据准备好,转而立即返回一个 EWOULDBLOCK 的错误,过一段时间再次发送 recvfrom 请求,在此期间进程可以做其他事情,不用一直等待,这就是非阻塞。

当一个应用进程循环调用 recvfrom 时,我们称之为轮询(polling),应用进程持续轮询内核,以查看某个操作是否就绪。Java 的 NIO 映射到 Linux 操作系统就是如上图所示的非阻塞 I/O 模型。

I/O复用模型

IO 多路复用使用 select/poll/epoll 函数,多个进程的 IO 都可以注册在同一个 select 上,当用户进程调用该 select 时,select 去监听所有注册好的 IO,如果所有被监听的 IO 需要的数据都没有准备好,那么 select 调用进程会被阻塞,只要任意一个 IO 的数据报套接字变为可读,即数据报已经准备好,select 就返回套接字可读这一条件,然后调用 recvfrom 把所读数据报复制到应用进程缓冲区。

强调一点就是,IO 多路复用模型并没有涉及到非阻塞,进程在发出 select 后,要一直阻塞等待其监听的所有 IO 操作至少有一个数据准备好才返回,强调阻塞状态,不存在非阻塞。

而在 Java NIO 中也可以实现多路复用,主要是利用多路复用器 Selector,与这里的 select函数类型,Selector 会不断轮询注册在其上的通道 Channel,如果有某一个 Channel 上面发生读或写事件,这个 Channel 处于就绪状态,就会被 Selector 轮询出来。关于 Java NIO 实现多路复用更多的介绍请查询相关文章。

I/O 多路复用的主要应用场景

  • 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字
  • 服务器需要同时处理多种网络协议的套接字

I/O多路复用的系统调用函数

目前支持 I/O 多路复用的系统调用函数有 select,pselect,poll,epoll。在 Linux 网络编程中,很长一段时间都使用 select 做轮询和网络事件通知。然而因为 select 的一些固有缺陷导致它的应用受到了很大的限制,比如 select 单个进程打开的最大句柄数是有限的。最终在 Linux 2.6 选择 epoll 替代了 select,Java NIO 和 AIO 底层就是用 epoll

信号驱动式I/O模型

应用进程预先向内核安装一个信号处理函数,然后立即返回,进程继续工作,不阻塞,当数据报准备好读取时,内核就为该进程产生一个信号通知进程,然后进程再调用 recvfrom 读取数据报。

信号驱动式IO不是异步的

信号驱动式 IO 在数据准备阶段是异步的,当内核中有数据报准备后再通知进程,但是在调用 recvfrom 操作进行数据拷贝时是同步的,所以总体来说,整个 IO 过程不能是异步的。

异步I/O模型 - AIO

应用进程调用 aio_read 函数,给内核传递描述符,缓存区指针,缓存区大小和文件偏移,并告诉内核当整个操作完成时如何通知进程,然后该系统调用立即返回,而且在等待 I/O 完成期间,我们的进程不被阻塞,进程可以去干其他事情,然后内核开始等待数据准备,数据准备好以后再拷贝数据到进程缓冲区,最后通知整个 IO 操作已完成。

Java 的 AIO 提供了异步通道 API,其操作系统底层实现就是这个异步 I/O 模型。

与信号驱动式I/O的区别

主要区别在于: 信号驱动式 I/O 是由内核通知我们何时去启动一个 I/O 操作,而异步 I/O 模型是由内核通知我们 I/O 操作何时完成。

5种I/O模型的比较

由上图可以再次看出,IO操作主要分为两个阶段:

  • 等待数据报准备阶段
  • 数据拷贝阶段

前4种 IO 模型都是同步 IO 模型,为什么说都是同步的,因为它们在第二步数据拷贝阶段都是阻塞的,这会导致整个请求进程存在阻塞的情况,所以是同步的,而异步 IO 模型不会导致请求进程阻塞。

I/O复用的实现

select/poll/epoll 都是 I/O 多路复用的具体实现,select 出现的最早,之后是 poll,再是 epoll。

select

有三种类型的描述符类型:readset、writeset、exceptset,分别对应读、写、异常条件的描述符集合。fd_set 使用数组实现,数组大小使用 FD_SETSIZE 定义。

timeout 为超时参数,调用 select 会一直阻塞直到有描述符的事件到达或者等待的时间超过 timeout。

成功调用返回结果大于 0,出错返回结果为 -1,超时返回结果为 0。

poll

pollfd 使用链表实现。

select 和 poll 比较

  1. 功能

    select 和 poll 的功能基本相同,不过在一些实现细节上有所不同。

    • select 会修改描述符,而 poll 不会;
    • select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 的描述符类型使用链表实现,没有描述符数量的限制;
    • poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
    • 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。
  2. 速度

    select 和 poll 速度都比较慢。

    • select 和 poll 每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
    • select 和 poll 的返回结果中没有声明哪些描述符已经准备好,所以如果返回值大于 0 时,应用进程都需要使用轮询的方式来找到 I/O 完成的描述符。
  3. 可移植性

    几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。

epoll

epoll_ctl() 用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。

从上面的描述可以看出,epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。

epoll 仅适用于 Linux OS。

epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。

epoll 对多线程编程更有友好,一个线程调用了 epoll_wait() 另一个线程关闭了同一个描述符也不会产生像 select 和 poll 的不确定情况。

epoll工作模式

epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。

  1. LT 模式

    当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。

  2. ET 模式

    和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。

    很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。

  1. select 应用场景

    select 的 timeout 参数精度为 1ns,而 poll 和 epoll 为 1ms,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。

    select 可移植性更好,几乎被所有主流平台所支持。

  2. poll 应用场景

    poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

  3. epoll 应用场景

    只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。

    需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。

    需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。