[TOC]
从Unix的角度来看,无论底层使用的IO技术是基于中断驱动的CPU介入的IO技术还是DMA(现代计算机的主流IO技术)这种无需CPU介入的IO技术,都是UNIX IO系统函数的内部实现,对于应用而言由操作系统暴露出来的通用的IO系统接口已经屏蔽了底层的实现细节。所以从应用开发而言,只需要关注application和system call之间的关系。
系统调用和内核态用户态的切换
系统调用也只是一类函数,只不过这个函数比较特殊,是由操作系统实现的,底层涉及到复杂的硬件关联,是操作系统提供给上层的抽象,以降低复杂性。这也是各种系统及体系结构分层的目的。
由于系统函数的特殊性以及操作系统基于安全的考虑,用户的应用是无法直接访问硬件设备的,所以需要通过系统调用进入os层面(就是常说的陷入内核态),os是可以和底层交互的,系统函数执行硬件相关的操作,如从网卡的buffer读数据,然后把数据作为系统函数的返回值返回给caller,也就是用户,在系统函数返回时,实际上已经退出os层了(从内核态切回用户态),而所谓用户空间就是在应用程序开辟的内存空间,用于承载系统调用返回的数据。
内核态和用户态的来回切换是很消耗性能的,所以无论是应用层还是系统层(主要还是靠系统层的优化),都致力于减少系统调用的次数以及数据拷贝的次数来提升性能。
Unix的I/O模型
Unix下有五种I/O模型:
- Blocking I/O Model
- Nonblocking I/O Model
- I/O Multiplexing Model
- Signal-Driven I/O Model
- Asynchronous I/O Model
ps:这些都是在内核层面的IO模型,其涉及的操作都是内核中的系统函数。
- Blocking IO是最为基础的IO模型,Unix I/O函数、RIO和ASNI C的标准IO库都是这种模型的实现,特征是blocking,无论数据是否就绪,系统函数都会阻塞直到操作完成。
- Nonblocking的调用多了一层检查机制,如果数据未就绪,函数会立即返回不会阻塞线程,但是实际的读写操作必然是阻塞的,也就是如果数据就绪,就会阻塞读写数据。
- I/O Multiplexing就是IO多路复用,OS提供了一个select系统函数来监听fd(文件描述符),它的优点在于一个select可以监听多个fd,当有fd就绪,select就会返回可用的fd,select是阻塞的,获取可用后的fd对其进行操作也仍是BIO过程。
- signal-driven是利用系统的信号,当描述符就绪时内核会发送通知signal,这和Nonblocking的区别是,就绪的检查不需要主动调用读写而是通过被动等待信号,当信号到达时,使用阻塞的读写函数进行读写。
- Asynchronous I/O即异步IO,异步的特征就是发起一个请求,然后就忘记这个请求,请求会在后台(不需要发起的线程介入)执行,执行完成后依据逻辑回调caller的函数来通知caller已经完成或者默默结束,实际的操作不需要caller介入。如发起一个异步的文件读操作,要求系统将文件A的数据读到内存a开始的数组中,如果需要读取完成的后续逻辑,主线程可以给这次请求配置一个callback函数,然后主线程执行其他操作,不再关心这个操作。系统完成指定操作后(不知道什么时候完成),会调用开始时配置的callback来“通知”主线程。这是完全异步的过程,发起请求后就不管了。
以上五种I/O模型,前四个都要主线程去主动介入数据的读写,数据的就绪和读写都是阻塞的过程,而等待完成等待可用这个过程,就是一个同步的过程,所以前4中都是同步IO;第五种的异步IO,正确的使用是主线程不会在发起请求后再介入数据的读写和后续操作(通俗的说就是发起请求之后就忘记了),如果主线程发起异步读后,还要主动去处理读出的数据(不是事先通过callback的方式在未来处理数据),比如Future式的异步,那不是真正的异步过程。
Java IO
编程语言是基于OS抽象的系统调用来完成和系统的交互的。
OIO
Java OIO是Java 1.0中的IO库,核心组件是InputStream、OutStream、Socket、File。采用的是ANSI C的标准IO库的流的抽象。实际上InputStream和OutputStream的读写操作就是基于native的C的实现。
- UNIX IO read,OIO的read基于这个系统函数
#include <unistd.h>
/*
read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.
If count is zero, read() returns zero and has no other results. If count is greater than SSIZE_MAX, the result is unspecified
*/
ssize_t read(int fd, void *buf, size_t count);
- Java FileInputStream read
/**
* Reads a byte of data from this input stream. This method blocks
* if no input is yet available.
*
* @return the next byte of data, or <code>-1</code> if the end of the
* file is reached.
* @exception IOException if an I/O error occurs.
*/
private native int read0() throws IOException;
/**
* Reads a subarray as a sequence of bytes.
* @param b the data to be written
* @param off the start offset in the data
* @param len the number of bytes that are written
* @exception IOException If an I/O error has occurred.
*/
private native int readBytes(byte b[], int off, int len) throws IOException;
每次调用read()方法都会陷入内核,调用系统的read函数,所以考虑到性能出现类用户缓冲区,一次多读一些数据(count>1)到用户的缓冲区,然后操作用户缓冲区的数据。
在OIO方式下的数据流动,以socket读写为例,socket读:socket缓冲区--->kernel缓冲区--->用户缓冲区,socket写:用户缓冲区--->kernel缓冲区--->Socket缓冲区,很明显,每次系统调用都会有2次数据拷贝过程。
虽然采用用户缓冲区的方式可以减少系统调用次数,但是每次调用的数据拷贝次数还是一样,对于大量的数据,来回的拷贝也很消耗性能,所以出现了内存映射和零拷贝技术。这些技术是OS层的,Java NIO中提供了语言层面的封装。
所以Java OIO的核心是:面向流、阻塞过程、标准IO。
NIO
Java NIO是NonBlocking 模型和IO multiplexing模型的实现,对应支持非阻塞的Channel和Selector选择器。核心组件:Buffer、Channel、Selector。
前面总结UNIX IO模型时说明过,非阻塞模型除了在数据未就绪时的读写为非阻塞外,真正的读写依然是阻塞的,所以其内部依旧是OIO的过程;IO多路复用是借助OS的select函数来完成对文件描述符的事件监听,事件触发(就绪)后的读写也依然是OIO的过程。那么NIO的优势在于何处。
- 面向缓冲区而不是流
- 借助select,只需要单个线程就可以处理多个fd(NIO抽象为channel,要求channel为非阻塞)
面向缓冲区而不是流是什么意思
Java NIO引入一个Buffer类,主要的堆内缓冲区"HeapByteBuffer"实际上就是对byte数组的封装。在OIO中,都是直接对流进行read或write操作,在NIO使用双向的channel对datasource进行了抽象(实际上在ANSI C的标准IO的流的抽象就是全双工的,由于socket的限制,标准IO中也不支持全双工,Java中这样读写分离的设计可能是为了简化使用,统一读和写分别使用了一个流吧),所有读channel的读写都是直接和Buffer交互,即读数据到Buffer中,然后操作Buffer,或者是put数据到buffer中,然后写buffer到channel中,数据的操作面向的是Buffer,而不是底层的流。这样的好处是不会直接暴露底层的API和细节。Buffer就是封装了缓冲区数组,以及支持在数组中前后移动的方法的封装,本质上的读写依然是标准IO的操作。可以参考Java NIO vs. IO
Selector
借助系统函数select或者epoll来通过一个线程管理多个fd,对于服务端来说可算是解放了每个socket一个线程的简陋操作。虽然select过程依然是阻塞的,但是可以只需要阻塞一个线程就可以监听多个fd,另外其基于事件的监听给fd的处理提供了很大的便利性。selector需要其监听的channel是非阻塞的。
Channel非阻塞的意义
对于同步IO来说,非阻塞channel的意义主要在于select,如果不使用select,对channel的读写就需要轮询,和阻塞等待的差别不大。
内存映射、直接缓冲区和零拷贝技术
MappedByteBuffer和DirectByteBuffer提供了内存映射和直接缓冲区的封装实现,另外FileChannel的transferTo、transFrom在特定的平台上(Linux kernel2.4及以后,支持gather operation)还支持零拷贝技术(基于Linux的sendfile系统函数)。
AIO
也称Java NIO2,是NIO的补充,主要新增了对异步的支持。AIO的设计一般有未来式(使用Future)和回调式(配置callback方法),因为future的get()实际上是阻塞方法,所以未来式的AIO不是真正意义的异步。
- AsynchronousFileChannel by future
AsynchronousFileChannel asynchronousFileChannel = ...;
ByteBuffer buffer = ...;
Future<Integer> future = asynchronousFileChannel.read(buffer, position);
// 阻塞,等待完成
future.get();
// ... 后续继续处理数据,所以这个形式实际上是同步的操作。
- AsynchronousFileChannel by callback(真正的异步)
// 配置回调函数
asynchronousFileChannel.read(buffer, 0, null, new CompletionHandler<Integer, Object>() {
// 动作完成后回调
@Override
public void completed(Integer result, Object attachment) {
if (result != -1) {
buffer.flip();
System.out.println(new String(buffer.array(), 0, buffer.limit(), StandardCharsets.UTF_8));
}
}
// 发生异常时回调
@Override
public void failed(Throwable exc, Object attachment) {
exc.printStackTrace();
}
});
除了异步文件通道AsynchronousFileChannel,网络模块也有对应的AsynchronousSocketChannel和AsynchronousServerSocketChannel,他们都支持未来式和回调式的操作。
Reference
- JAVA IO 以及 NIO 理解
- I/O Models
- CSAPP--系统级IO
网友评论