五种IO模型
按照《Unix网络编程》的划分,IO模型可以分为下面五种:
- 阻塞IO: blocking I/O
- 进程处于阻塞模式时,让出CPU,进入休眠状态,直到数据到达并且被复制到应用程序缓冲区中或者发生错误才返回
- Linux下的I/O操作默认是阻塞I/O,即open和socket创建的I/O都是阻塞I/O
- 非阻塞IO: nonblocking I/O
- 可以通过fcntl或者open时使用O_NONBLOCK参数,将fd设置为非阻塞的I/O
- 进程并不投入睡眠,而是立即返回一个错误
- 应用程序需要不停的轮询(polling)内核来检查是否I/O操作已经完成
- 应用程序持续轮询,需要耗费大量cpu时间,所以非阻塞模式的使用并不普遍
- IO多路复用: I/O multiplexing
- 阻塞在select或者poll或者epoll(2.6内核+),而不是阻塞在真正的IO操作
- 通常需要非阻塞I/O配合使用
- 信号驱动IO: sigal driven I/O (SIGIO)
- 内核在描述符就绪时发送SIGIO信号通知用户进程
- 信号驱动式IO告诉我们何时可以启动io操作。这点与IO多路复用类似。
- 异步IO: asynchronous I/O (the POSIX aio_ functions)
- 不阻塞
- 内核完成整个io操作后才通知我们io操作已经完成
可以将输入操作io分为两个步骤:
- 内核等待数据准备好;
- 将数据从内核缓冲区复制到进程缓冲区
根据阻塞的定义,可以看到阻塞式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它还多了一次系统调用。
但是它有两个好处:
- select可以同时监听多个文件描述符,而这些文件描述符其中的任意一个进入读就绪状态,select()函数就可以返回。这也是它被称为IO复用的原因。
- 如果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标志位的数据结构来进行下一步处理。这样所带来的缺点是:
- 单个进程可监视的fd数量被限制
- 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
- 对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。它三个主要的设计目标:
- 一个能够批量获取文件属性的文件系统接口,去掉和特定文件系统相关的API,还有一个用于引入标准文件系统实现的服务提供者接口。
- 提供一个套接字和文件能够进行异步IO(与轮询、非阻塞相对)操作的API。
- 完成JSR-51中定义的套接字——通道功能,包括额外对绑定、选项配置和多播数据报的支持。
Java NIO.2
NIO.2是一组新的类和方法,主要存在于java.nio包内。主要有如下变更:
- 完全取代了java.io.File与文件系统的交互
- 提供了新的异步处理类,让你无需手动配置线程池和其他底层并发控制,便可在后台线程中执行文件和网络IO操作。
- 引入新的Network-Channel构造方法,简化了套接字与通道的编码工作。
我们这里主要介绍最重要的异步I/O操作。
异步IO
Java 7中有三个新的异步通道:
- AsynchronousFileChannel: File AIO
- AsynchronousSocketChannel: TCP AIO,支持超时
- AsynchronousServerSocketChannel: TCP AIO
- 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。