1 什么是I/O
在UNIX世界里一切皆是文件,而文件是什么?文件就是一串二进制流而已,其实不管是Socket,还是FIFO(先进先出队列)、管道、终端。对计算机来说,一切都是文件,一切都是流。在信息交换的过程中,计算机都是对这些流进行数据的收发操作,简称为I/O操作,包括往流中读出数据、系统调用Read、写入数据、系统调用write。不过计算机里有那么多流,怎么知道要操作哪个流呢?实际上由操作系统内核创建文件描述符(FD)来标识的,一个FD就是一个非负数,所以对这个整数的操作就素对这个文件(流)的操作。创建一个Socket,通过系统调用会返回一个FD,那么剩下的对Socket操作就会转化为对这个描述符的操作,这又是一种分层和抽象的思想。
2 I/O交互流程
通常用户进程中的一次完整I/O交互流程分为两个阶段,首先是经过内核空间,也就是由操作系统处理;紧接着就是到用户空间,也就是交由应用程序。具体交互流程如下图:
内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中,Linux使用两级保护机制:0级供内核使用,3级供用户程序使用。每个进程都有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的。最高的Q1G虚拟内核空间则为所有进程及内核共享。
操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,两者不能简单的使用指针传递数据。因为Linux使用的虚拟内存机制,必须通过系统调用请求Kernel来协助完成I/O操作,内核会为每个I/O设备维护一个缓冲区,用户空间的数据可能被换出,所以当内核空间使用用户空间的指针时,对应的数据可能不在内存中。
对于一个输入操作来说,进程I/O系统调用后,内核会先看缓冲区中有没有相应的缓存数据,如果没有再到设备中读取。因为设备I/O一般速度较慢,而且等待,内核缓冲区有数据则直接复制到进程空间。所以,一个网络输入数据通常包括两个不同阶段。
1、等待网络数据到达网卡,然后将数据读取到内核缓冲区。
2、从内核缓冲区复制数据,然后拷贝到用户空间。
I/O有内存I/O、网络I/O和磁盘I/O三种,通常我们说的I/O指的是后两者。如下图所示是I/O通信过程的调度示意图:
3 五种I/O通信模型
在网络环境下,通俗的讲,将I/O分为两步:第一步是等待,第二步是数据搬迁。
如果想要提高I/O效率,需要将等待时间降低。因此发展出来五种I/O模型,分别是:阻塞I/O模型,非阻塞I/O模型,多路复用I/O模型,信号驱动I/O模型,异步I/O模型。其中,前四种被称为同步I/O,下面对每一种I/O模型进行详细分析。
3.1 阻塞I/O模型
阻塞I/O模型通信过程如下图所示:
当用户进程调用了recvfrom这个系统调用,内核就开始了I/O的第一个阶段:准备数据。对于网络I/O来说,很多时候数据在一开始还没有到达,这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程就会阻塞,当数据准备好时,它就会将数据从内核拷贝到用户内存,然后返回结果,用户进程才接触阻塞的状态,重新运行起来。几乎所有的开发者第一次接触到的网络编程都是从listen()、send()、recv()等接口开始的,这些接口都是阻塞型的。阻塞I/O的特性总结如下:
3.2 非阻塞I/O模型
非阻塞I/O模型的通信过程示意图如下:
当用户进程发出read操作时,如果内核中的数据还没有准备好,那么它不会阻塞用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作,并不需要等待,而是马上就能得到一个结果,用户进程判断结果是一个error时,他就知道数据还没有准备好。于是它可以再次发起read操作,一旦内核中的数据准备好,并且再次收到用于进程的系统调用,那么它会马上将数据拷贝到用户内存,然后返回,非阻塞型接口相比于阻塞型接口的显著差异在于,再被调用之后立即返回。非阻塞型I/O模型的特性总结如下图:
非阻塞模式套接字与阻塞模式套接字相比,不容易使用。使用非阻塞模式套接字,需要编写更多的代码,但是,非阻塞模式套接字在控制建立多个连接、数据的收发量不均、时间不定时,具有明显优势。
3.3 多路复用I/O模型
多路复用I/O模型的通信过程如下图:
多个进程的I/O可以注册到一个复用器(Selector)上,当用户进程调用该Selector,Selector会监听注册进来的所有I/O,如果Selector监听的所有I/O 在内核缓冲区都没有可读数据,select调用进程会被阻塞,而当任一I/O在内核缓冲区中有可读数据时,select调用就会返回,而后select调用进程可以自己活通知另外的进程(注册进程)再次发起读取I/O,读取内核中准备好的数据,多个进程注册I/O 后,只有一个select调用进程被阻塞。
多路复用I/O 相对阻塞和非阻塞更难简单说明,所以额外解释一段,其实多路复用I/O 模型和阻塞I/O 模型并没有太大的不同,事实上,还更差一些,因为这里需要使用两个系统调用(select和recvfrom),而阻塞I/O 模型只有一个系统调用(recvfrom)。但是,用Selector的优势在于它可以同时处理多个连接,所以如果处理的连接数不是很多,使用select/epoll的Web Server不一定比使用多线程加阻塞I/O 的Web Server性能更好,可能延迟还更大,select/epoll的优势并不是对于单个连接能处理更好,而是能处理更多的连接。多路复用I/O模型的特性总结如下:
3.4 信号驱动I/O模式
信号驱动I/O模式通信过程如下图;
信号驱动I/O是指进程预先告知内核,向内核注册一个信号处理函数,然后用户集成返回不阻塞,当内核数据就绪时就会发送一个信号给进程,用户进程便在信号处理函数中调用I/O读取数据。从上图可以看出,实际上I/O 内核拷贝到用户进程的过程还是阻塞的,信号驱动I/O并没有实现真正的异步,因为通知到进程之后,依然由进程来完成I/O 操作。这和后面的异步I/O 容易混淆,需要理解I/O 交互并结合五种I/O 模型进行比较阅读。信号驱动I/O 模型的特性总结如下:
3.5 异步I/O模型
异步I/O模型的通信过程如下图:
用户进程发起aio_read操作后,给内核传递与read相同的描述符、缓冲区指针、缓冲区大小三个参数及文件偏移,告诉内核当某个操作完成时,如何通知我们立即就可以开始去做其他的事情;而另一方面,从内核的角度,当它收到一个aio_read之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成后,内核会给用户进程发送一个信号,告诉它aio_read操作完成。
异步I/O的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们,这种模式与信号驱动I/O模型的区别在于,信号驱动I/O模型是由内核通知我们何时可以启动一个I/O操作,这个I/O操作由用户自定义的信号函数实现,而异步I/O模型由内核告知我们I/O操作何时完成。
异步I/O模型的特性总结如下:
3.6 容易混淆的概念澄清
在实际开发中,经常会听到同步、异步、阻塞、非阻塞这些概念,每次到遇到都会疑惑。下面我们来探讨它们之间的区别和联系。
3.6.1 异步和同步
同步和异步其实是指CPU时间片的利用,主要看请求发起方对消息结果的获取是主动发起的,还是别动通知的,如下图所示:
如果是请求方主动发起的,一直在等待应答结果(同步阻塞),或者可以先去处理其他事情,但要不断轮询查看发起的请求是否有应答结果(同步非阻塞),因为不管如何都要发起方主动获取消息结果,所以形式上还是同步操作。如果有服务方通知的,也就是请求方发出请求后,要么一直等待通知(异步阻塞),要么先去干自己的事(异步非阻塞) 。当事情处理完成后,服务方会主动通知请求方,它的请求已经完成,这就是异步。异步通知的方式一般通过状态改变、消息通知或者回调函数来完成,大多数时候采用的都是回调函数。
3.6.2 阻塞和非阻塞
阻塞和非阻塞在计算机的世界里,通常针对I/O操作,如网络I/O和磁盘I/O等。那么什么是阻塞和非阻塞呢?简单来说,就是我们调用一个函数后,在等待这个函数返回结果之前,当先的线程是出于挂起状态,就意味着当前线程什么都不干,只等着获取结果,这就是同步阻塞;如果仍然是运行装填,就意味着当前线程是可以继续处理其他任务的,但要时不时的看一下是否有结果,这就是同步非阻塞。具体如下图:
3.6.3 各I/O模型的对比与总结
其实前四种I/O模型都是同步I/O操作,它们的区别在于第一阶段,而第二阶段是一样的:在数据从内核拷贝到应用缓冲区期间(用户空间),进程阻塞于recvfrom调用。
有人会认为,NIO并没有被阻塞。这里有个非常特殊的地方,定义中所指的“I/O Operation”是指真实的I/O操作。NIO在执行recvfrom的时候,如果内核(Kernel)的数据没有准备好,这时候不会阻塞进程。但是,当内核中数据准备好的时候,recvfrom会把数据从内核拷贝到用户内存中,这个时候进程就被阻塞了。在这段时间内,进程是被阻塞的。下图是各I/O模型的阻塞状态对比。
从上图可以看出,阻塞程度:阻塞I/O > 非阻塞I/O > 多路复用I/O > 信号驱动I/O > 异步I/O,效率是由低到高的。最后,再看一下表,从多维度总结了各I/O模型之间的差异,可以加深理解。
4 从BIO到NIO的演进
下表总结了Java BIO 和 NIO 之间的主要差异。
4.1 面向流与面向缓冲
Java NIO和BIO之间第一个最大的区别是,BIO是面向流的,NIO是面向缓冲区的。BIO面向流意味着每次从流中读取一个或多个字节,直至读取所有字节,它们没有被缓冲在任何地方。此外,不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。NIO的缓冲处理方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可以在缓冲区中前后移动。这就增加了处理过程的灵活性。但是,还需要检查该缓冲区是否包含所有需要处理的数据,而且,要确保当更多的数据读入缓冲区时,不能覆盖缓冲区里尚未处理的数据。
4.2 阻塞与非阻塞
BIO的各种流是阻塞的。这意味着,当一个线程调用read()或write()时,该线程被阻塞,直到有一些数据被读取,或者数据完全写入。该线程在此期间不能在干任何事情。NIO的非阻塞模式,是一个线程从某通道(Channel)发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据,就什么都不会获取,而不是保持线程阻塞,所以直到数据变成可读取之前,该线程可以继续做其他的事情。非阻塞写入也是如此。一个线程请求写入某通道一下数据,但不需要等待它完成写入,这个线程同时可以去做别的事情。线程通常将非阻塞I/O 的空闲时间用于在其他通道上执行I/O操作,所以一个单独的线程现在可以管理多个I/O通道。
4.3 选择器在I/O中的应用
NIO的选择器(Selector)允许一个单独的线程监视多个输入通道,可以注册多个通道使用一个选择器,然后使用一个单独的线程来"选择"通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制使一个单独的线程很容易管理多个通道。
5 Java AIO详解
JDK1.7(NIO2.0)才是实现真正的异步AIO,把I/O读写操作完全交给操作系统,学习了Linux Epoll 模式下,来做一些演示。
5.1 AIO基本原理
Java AIO 处理API中,重要的三个类分别是:AsynchronousServerSocketChannel.java(服务端)、AsynchronousSocketChannel.java(客户端)、CompletionHandler.java(用户处理器)。CompletionHandler接口实现应用程序向操作系统发起I/O请求,当完成后处理具体逻辑,否则做自己该做的事情,“真正”的异步I/O需要操作系统更强的支持。在多路复用I/OO模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步I/O模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区中,内核在I/O完成后通知用户线程直接使用即可。异步I/O模型使用Proactor设计模式实现这一个机制,如下图所示:
5.2 AIOdemo
基于AIO先写一段简单的代码,来感受一下服务端和客户端的交互过程。
public class AIOServer {
private int port;
public static void main(String[] args) {
int port = 8000;
new AIOServer(port);
}
public AIOServer(int port) {
this.port = port;
listen();
}
private void listen(){
try {
ExecutorService service = Executors.newCachedThreadPool();
AsynchronousChannelGroup threadGroup = AsynchronousChannelGroup.
withCachedThreadPool(service, 1);
final AsynchronousServerSocketChannel server =
AsynchronousServerSocketChannel.open(threadGroup);
server.bind(new InetSocketAddress(port));
System.out.println("服务已启动,监听端口:"+port);
server.accept(null,
new CompletionHandler<AsynchronousSocketChannel, Object>() {
final ByteBuffer buffer = ByteBuffer.allocate(1024);
@Override
public void completed(AsynchronousSocketChannel result, Object attachment) {
System.out.println("I/O操作成功,开始获取数据");
try {
buffer.clear();
result.read(buffer).get();
buffer.flip();
result.write(buffer);
buffer.flip();
}catch (Exception e){
e.printStackTrace();
}finally {
try {
result.close();
server.accept(null,this);
}catch (Exception e){
e.printStackTrace();
}
}
}
@Override
public void failed(Throwable exc, Object attachment) {
System.out.println("I/O操作失败");
}
});
try {
Thread.sleep(Integer.MAX_VALUE);
}catch (InterruptedException e){
e.printStackTrace();
}
}catch (Exception e){
e.printStackTrace();
}
}
}
上述代码的主要功能就是开启一个监听端口,然后在CompletionHandler 中处理接收到消息以后的逻辑,将接收到的信息再输出给客户端。下面来看客户端的代码:
public class AIOClient {
private AsynchronousSocketChannel client = null;
public AIOClient() throws Exception{
client = AsynchronousSocketChannel.open();
}
public void connect(String host,int port) throws Exception{
client.connect(new InetSocketAddress(host, port), null, new CompletionHandler<Void, Void>() {
@Override
public void completed(Void result, Void attachment) {
try {
client.write(ByteBuffer.wrap("这是一条测试数据".getBytes())).get();
System.out.println("已发送至服务器");
}catch (Exception e1){
e1.printStackTrace();
}
}
@Override
public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
final ByteBuffer buffer = ByteBuffer.allocate(1024);
client.read(buffer, null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
System.out.println("I/O操作完成" + result);
System.out.println("获取反馈结果" + new String(buffer.array()));
}
@Override
public void failed(Throwable exc, Object attachment) {
exc.printStackTrace();
}
});
try {
Thread.sleep(Integer.MAX_VALUE);
}catch (InterruptedException e2){
e2.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
new AIOClient().connect("127.0.0.1",8000);
}
}