五种IO模型

按照《Unix网络编程》的划分,IO模型可以分为下面五种:

  1. 阻塞IO: blocking I/O
    • 进程处于阻塞模式时,让出CPU,进入休眠状态,直到数据到达并且被复制到应用程序缓冲区中或者发生错误才返回
    • Linux下的I/O操作默认是阻塞I/O,即open和socket创建的I/O都是阻塞I/O
  2. 非阻塞IO: nonblocking I/O
    • 可以通过fcntl或者open时使用O_NONBLOCK参数,将fd设置为非阻塞的I/O
    • 进程并不投入睡眠,而是立即返回一个错误
    • 应用程序需要不停的轮询(polling)内核来检查是否I/O操作已经完成
    • 应用程序持续轮询,需要耗费大量cpu时间,所以非阻塞模式的使用并不普遍
  3. IO多路复用: I/O multiplexing
    • 阻塞在select或者poll或者epoll(2.6内核+),而不是阻塞在真正的IO操作
    • 通常需要非阻塞I/O配合使用
  4. 信号驱动IO: sigal driven I/O (SIGIO)
    • 内核在描述符就绪时发送SIGIO信号通知用户进程
    • 信号驱动式IO告诉我们何时可以启动io操作。这点与IO多路复用类似。
  5. 异步IO: asynchronous I/O (the POSIX aio_ functions)
    • 不阻塞
    • 内核完成整个io操作后才通知我们io操作已经完成

可以将输入操作io分为两个步骤:

  1. 内核等待数据准备好;
  2. 将数据从内核缓冲区复制到进程缓冲区

根据阻塞的定义,可以看到阻塞式IO、非阻塞IO、IO复用、信号驱动式都是同步IO模型,因为它们需要IO操作,而异步io模型,kernel帮用户进行了IO,只有异步IO模型是异步IO操作。

按照这个对应关系,Java的Streaming API是属于阻塞IO,同时提供了非阻塞IO的IO模型。而1.4引入的NIO属于IO复用,1.7引入的NIO.2则是异步IO。

前面我们已经介绍过Streaming API和NIO,现在让我们来看看java 1.7引入的AIO模型。

TIPS 为什么说IO复用不是真正意义上的异步IO

其实就是Java NIO和Java AIO的区别。事实上NIO是彻头彻尾的Blocking IO:调用select监听文件描述符需要block,select返回之后再次对ready的文件描述符进行操作需要block。而且相对于普通的Blocking IO它还多了一次系统调用。

但是它有两个好处:

  1. select可以同时监听多个文件描述符,而这些文件描述符其中的任意一个进入读就绪状态,select()函数就可以返回。这也是它被称为IO复用的原因。
  2. 如果select返回的时候,调用相应的Blocking IO操作一般不会“阻塞”,因为文件描述符已经是ready了。需要的时间只是将数据从内核copy到用户空间(或者相反)。

但是AIO就不一样了,它是真正的异步IO。当我们调用一个异步IO函数时,内核会马上返回。具体的I/O和数据的拷贝全部由内核来完成,我们的程序可以继续向下执行。当内核完成所有的I/O操作和数据拷贝后,内核将通知我们的程序。

当AIO函数返回的时候,不是文件描述符ready,然后你过去对他进行操作。而是数据完全ready!已经从内核copy到用户空间的缓冲区,或者反之。

最近很流行的node.js就是全面采用这种IO模式,所以编写node.js的感觉就是根本停不下来。因为它不会阻塞,爽到你也停不下来。

基础知识

1. select,poll,epoll简介

多路复用是通信领域的术语,指把多个信号组合起来在一条物理信道上进行传输,在远距离传输时可大大节省电缆的安装和维护费用。这里指让一个线程或进程同时处理多个网络连接

select

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

  1. 单个进程可监视的fd数量被限制
  2. 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
  3. 对socket进行扫描时是线性扫描

poll

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

epoll

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。

在前面说到的复制问题上,epoll使用mmap减少复制开销。

还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

Java I/O简史

1. Java 1.0 ~ 3.0

这个期间的Java IO就是前面介绍的阻塞式的Stream IO。开发人员在开发需要IO支持的应用时,经常会面临如下问题:

  • 没有数据缓冲区或者通道的概念,开发人员需要编程处理很多底层细节
  • I/O操作会被阻塞,扩展能力受限
  • 所支持的字符集编码有限,需要进行很多手工编码工作来支持特定的硬件。
  • 不支持正则表达式,数据处理困难。

在Java 1.4发布之前,Java一直没能在服务器开发领域得到重用,主要原因就是缺乏对非阻塞I/O的支持。

2. Java 1.4

2002年在Java 1.4中引入了 NIO,主要有如下改进:

  • 为I/O操作抽象出缓冲区和通道层
  • 字符集的编码和解码能力
  • 内存映射文件接口
  • 实现非阻塞I/O的能力
  • 基于流行的Perl实现的正则表达式类库

NIO提供了非阻塞的IO方式,但是对于文件系统中的额我你就和目录处理,支持力度还是不够:

  • 在不同的平台中对文件名的处理方式不一致
  • 没有统一的文件属性模型(比如读写访问权限)
  • 遍历目录困难,没有直接支持目录树导航的类或者方法
  • 不能使用平台/操作系统的特性(不支持符合链接)
  • 不支持异步IO

3. Java 1.7

Java 7 引入了 NIO.2 API。它三个主要的设计目标:

  1. 一个能够批量获取文件属性的文件系统接口,去掉和特定文件系统相关的API,还有一个用于引入标准文件系统实现的服务提供者接口。
  2. 提供一个套接字和文件能够进行异步IO(与轮询、非阻塞相对)操作的API。
  3. 完成JSR-51中定义的套接字——通道功能,包括额外对绑定、选项配置和多播数据报的支持。

Java NIO.2

NIO.2是一组新的类和方法,主要存在于java.nio包内。主要有如下变更:

  • 完全取代了java.io.File与文件系统的交互
  • 提供了新的异步处理类,让你无需手动配置线程池和其他底层并发控制,便可在后台线程中执行文件和网络IO操作。
  • 引入新的Network-Channel构造方法,简化了套接字与通道的编码工作。

我们这里主要介绍最重要的异步I/O操作。

异步IO

Java 7中有三个新的异步通道:

  1. AsynchronousFileChannel: File AIO
  2. AsynchronousSocketChannel: TCP AIO,支持超时
  3. AsynchronousServerSocketChannel: TCP AIO
  4. AsynchronousDatagramChannel: UDP AIO

在使用新的异步IO时,主要有两种方式——Future轮询和Callback回调。下面我们以一个简单的例子说明这两种方式:

例子

从硬盘上的文件里读取100,000个字节。

1. Future轮询

try{
	Path file = Paths.get("/usr/argan/foobar.txt");

	AsynchronousFileChannel channel = AsynchronousFileChannel.open(file);

	ByteBuffer buffer = ByteBuffer.allocate(100_000);
	Future<Integer> result = channel.read(buffer, 0);

	while(!result.isDone()){
		// do something
	}

	Integer bytesRead = result.get();
	System.out.println("Bytes read [" + bytesRead + "]");
}catch(IOException | ExecutionException | InterruptedException e){
	System.err.println(e.getMessage());
}

其实底层JVM为执行这个任务创建了线程池和通道组。具体可以参考AsynchronousFileChannel

An AsynchronousFileChannel is associated with a thread pool to which tasks are submitted to handle I/O events and dispatch to completion handlers that consume the results of I/O operations on the channel. The completion handler for an I/O operation initiated on a channel is guaranteed to be invoked by one of the threads in the thread pool (This ensures that the completion handler is run by a thread with the expected identity). Where an I/O operation completes immediately, and the initiating thread is itself a thread in the thread pool, then the completion handler may be invoked directly by the initiating thread. When an AsynchronousFileChannel is created without specifying a thread pool then the channel is associated with a system-dependent default thread pool that may be shared with other channels. The default thread pool is configured by the system properties defined by the AsynchronousChannelGroup class.

2. Callback回调

Future其实本质上还是轮循的方式,回调式才是真正的AIO。其基本思想是主线程会派一个侦查员CompletionHanlder到独立的线程中执行IO操作。这个侦查员将带着IO操作的结果返回到主线程中,这个结果会触发它自己的completed或者failed方法(你需要重写这两个方法)

  • void completed(V result, A attachment) - executes if a task completes with a result of type V.
  • void failed(Throwable e, A attachment) - executes if the task fails to complete due to Throwable e.

    try{ Path file = Paths.get(“/usr/argan/foobar.txt”); AsynchronousFileChannel channel = AsynchronousFileChannel.open(file);

      ByteBuffer buffer = ByteBuffer.allocate(100_000);
    
      channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>(){
          public void completed(Integer result, ByteBuffer attachment){
              System.out.println("Bytes read [" + result + "]");
          }
    
          public void failed(Throwable exception, ByteBuffer attachment){
              System.err.println(exception.getMessage());
          }
      });   }catch(IOException e){
      System.err.println(e.getMessage());	   }
    

上面的例子是基于文件的AsynchronousFileChannel,但是基于网络套接字的AsynchronousServerSocketChannel和AsynchronousSocketChannel也是一样的pattern。

参考文档

  1. An NIO.2 primer, Part 1: The asynchronous channel APIs
  2. NIO and NIO2 强烈推荐!
  3. 6.2 I/O Models