一篇文章带你彻底搞懂NIO

本篇文章目的在于基本概念和原理的解释,不会贴过多的使用代码。

什么是NIO

Java NIO (New IO)是 Java 的另一个 IO API (来自 java1.4) ,意味着可以替代标准的 Java IO API和 Java Networking API。 提供了一种与标准 IO API 不同的 IO 工作方式。

注意:Java的NIO只是说IO API,阻塞非阻塞才是IO的模型。

也有人称NIO为No-Blocking IO,非阻塞IO,但是这么说并不严谨。因为对于基础的IO操作API(比如文件IO,FileChannel),还是阻塞的模型。只有对Networking IO API才可以使用非阻塞的模型(configureBlocking(false))。

Java NIO中的Networking IO API,支持非阻塞IO模型,还实现了IO多路复用(IO Multiplexing)。对于服务端来说,可以用更少的线程支持更多的并发,大幅度提升了性能。

NIO中的阻塞与非阻塞

阻塞与非阻塞是从线程的角度出发的,这里指的是线程状态。

阻塞

当进行IO读写时,线程是阻塞的状态。此时会让出cpu控制权,不会占用cpu资源。

什么?不占用CPU资源?那是不是代表阻塞模型更好呢?

答案是并不是,虽然阻塞状态不会占用CPU,但是会发生线程的切换,线程切换时会有上下文保存转换的过程,需要CPU调度,是一个很昂贵的操作。

Java NIO中的基础IO API(非Networking IO API)还是阻塞的方式,只是使用方式从面向流(stream)编程面向块(buffer)了,和BIO本质上并没有什么区别。

非阻塞

非阻塞是指在进行IO操作的时候,如果设备还未准备好(比如socket还没有收到数据),操作会直接返回结果,不会让当前线程进入阻塞状态。

这样的优点是,使用者可以自行决定在数据未准备好时的操作。线程可以在没有数据期间去执行其他操作。
Networking API可以配置为非阻塞模型Channel.configureBlocking(false),配合Selector来实现多路复用功能。简单的说就是一个Selector监听多个socket io(对于unix系统来说,socket也是一个fd,也属于io),可以在一个线程中支持多个连接。当然在实际服务器开发时,就算是NIO模型,有些程序也不会只使用一个线程;但相比传统的Blocking IO方式来说,需要的线程数量也会大大减少了。(redis中就是使用了IO多路复用技术,并且只有一个线程监听socket io)

AIO

AIO 是 Java 1.7 之后引入的包,是 NIO 的升级版本,新增了提异步非阻塞的 IO 操作方式,所以人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会执行回调通知相应的线程进行后续的操作。

多路复用

在I/O编程过程中,当需要同时处理多个客户端请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O多路复用技术通过把多个I/O的阻塞复用到同一个Select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型相比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些线程和进程的运行,降低了系统的维护工作量,节省了系统的资源,I/O多路复用的主要应用场景如下:

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

目前支持I/O多路复用的系统调用又select/pselect/poll/epoll。

select/epoll

select/epoll的介绍摘自https://zhuanlan.zhihu.com/p/...

select

select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。

一篇文章带你彻底搞懂NIO

当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。

一篇文章带你彻底搞懂NIO

所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。如下图所示。

一篇文章带你彻底搞懂NIO

经由这些步骤,当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。

这种简单方式行之有效,在几乎所有操作系统都有对应的实现。

但是简单的方法往往有缺点,主要是:

其一,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。

其二,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。

那么,有没有减少遍历的方法?有没有保存就绪socket的方法?这两个问题便是epoll技术要解决的。

补充说明: 本节只解释了select的一种情形。当程序调用select时,内核会先遍历一遍socket,如果有一个以上的socket接收缓冲区有数据,那么select直接返回,不会阻塞。这也是为什么select的返回值有可能大于1的原因之一。如果没有socket有数据,进程才会阻塞。

select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。

一篇文章带你彻底搞懂NIO

select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。

一篇文章带你彻底搞懂NIO

epoll

epoll是在select出现N多年后才被发明的,是select和poll的增强版本。epoll通过以下一些措施来改进效率。

原理:

创建epoll对象

如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。

一篇文章带你彻底搞懂NIO

创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。

维护监视列表

创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。

一篇文章带你彻底搞懂NIO

当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。

接收数据

当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。

一篇文章带你彻底搞懂NIO

eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。

当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。

阻塞和唤醒进程

假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
一篇文章带你彻底搞懂NIO

当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。

一篇文章带你彻底搞懂NIO

参考