本篇文章目的在于基本概念和原理的解释,不会贴过多的使用代码。
什么是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的等待队列中。
当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。如下图所示。
经由这些步骤,当进程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阻塞进程。显而易见的,效率就能得到提升。
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
epoll
epoll是在select出现N多年后才被发明的,是select和poll的增强版本。epoll通过以下一些措施来改进效率。
原理:
创建epoll对象
如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。
创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。
维护监视列表
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
接收数据
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
阻塞和唤醒进程
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。