乐趣区

一篇文章带你彻底搞懂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

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 发生了变化。

参考

  • Netty 权威指南
  • https://zhuanlan.zhihu.com/p/…
  • http://tutorials.jenkov.com/j…
退出移动版