之前一直在看关于NIO和AIO的文章,看的越多越发觉得琐碎,于是决定系统地从Unix的IO模型看起,一路学习到Java的IO模型,下面谈谈我对这两个系统上的IO模型的理解。
提一下Unix系统,这是一个1960年出现的第一个比较成熟的分时操作系统,在我看来也是对目前技术发展影响最深远的操作系统,目前主流的开发OS如Linux,IOS,Android都是类Unix系统,就是他们都是基于Unix系统,去复刻Unix的功能,同时扩展自己的功能的,而Java最大的一个特性就是平台无关性,他的虚拟机底层针对不同的操作系统有对应的实现让我们在开发的时候不需要处理不同的操作系统的特性,而Unix作为开发系统中的“老大哥”,他其中的IO模型思想必然会影响到Java的IO模型设计,所以理解Unix中的IO模型对理解Java的IO模型有非常大的帮助。
提及IO模型,不得不说到的四个概念就是:同步,异步,阻塞和非阻塞,在网上关于这四个概念的争论非常多,这里我参考了比1较权威的《Unix网络编程》从UNIX的角度去理解这些概念。
UNIX中可用的IO模型时以下5种:
1.阻塞式IO
2.非阻塞式IO
3.IO复用
4.信号驱动式IO
5.异步IO
介绍之前先说一下Unix系统的IO操作是包括两步的,第一步是等待数据准备好,比如在通过UDP网络编程时,我们要先等待所有IP分组到达本机,然后把相应的数据复制到Unix内核的某个缓冲区。第二步就是把内核缓冲区的数据复制到应用进程的缓冲区中,这样一个IO操作就完成了。
阻塞式IO:这是Unix中最流行的IO模型,他的特点就是他第一个阶段是阻塞的,比如我们在Unix系统中调用recvfrom这样一个系统调用(Unix下的一个函数,用于接收socket中的数据),如果第一阶段中的数据报没有准备好,那么发起这个请求的进程会一直阻塞直到数据报准备好进行第二步:数据报到达后内核将数据从内核缓存复制到用户空间(注意这里的进程仍然是阻塞的)。
(如图即为阻塞IO模型)
非阻塞IO模型:
这种相对于阻塞IO的最大改变就是第一阶段不再是阻塞的了,仍旧拿recvfrom这个系统调用为例,进程发送这个请求时会告诉内核,如果第一阶段的数据报还未到达,不要把请求的进程投入睡眠队列中,而是返回一个错误结果(EWOULDBLOCK),那么进程在接到这个结果后就知道数据报还未到达,就会在一定时间之后再次发送请求(我们把这一过程称为轮询),直到数据报到达并复制如内核的缓冲区,然后进行第二步数据的复制,这里注意第二步是阻塞的。
IO复用模型:
IO复用模型是针对多个套接字采用的一种IO模型,在第一阶段:他是通过select或者poll系统调用在多个套接字中寻找一个或多个数据报已经准备好的套接字,如果select或者poll调用返回值表示有套接字可读,那么我们就会调用recvfrom调用按套接字的顺序把内核中的数据报进行复制,也就是第二阶段。
IO复用和阻塞IO比较相似,只不过他是针对多个套接字进行IO操作的,注意在select或poll调用时是阻塞的,而且在调用recvfrom调用时是直接进入第二阶段,也是阻塞的。如果对Java的IO模型有了解的同学可能已经看出来NIO的雏形。
信号驱动式IO模型:
这种在Unix中使用地相对比较少,而且Java并没有对这种IO模型的支持,所以这里只是简单说下他本质就是安装一个SIGIO信号处理程序,然后立即返回。如果数据报准备好了就发送一个SIGIO信号通知我们用recvfrom调用去复制数据。
异步IO模型:
这是和前面四种差别最大的IO模型,进程在请求一个异步IO调用时会向内核发送自己的缓冲区地址,缓冲区大小等信息,告知内核自己去启动某个操作,然后立即返回去做别的事,此时内核就会自动完成第一阶段和第二阶段,等到第二阶段完成,数据被复制到了用户空间时内核再通知请求进程去处理。
要注意之前几种进程无论是阻塞还是非阻塞,都需要知道何时启动一个数据复制操作,而异步IO是直接在IO操作完成了才通知进程。
分析上面5种IO模型,我们可以发现前面四种有一个共同点:就是无论第一阶段是否会阻塞请求的进程,第二阶段一定会阻塞请求的进程,而第五种异步IO模型是唯一一个第一第二阶段都不会阻塞进程的IO模型,我们现在回看一下POSIX中是怎么定义同步IO和异步IO的:
同步IO操作:导致请求进程阻塞,知道IO操作完成。
异步IO操作:不导致请求进程阻塞。
既然只有前面四种模型会导致进程阻塞,我们可以得出一个结论:阻塞IO,非阻塞IO,IO复用和信号IO都是同步IO模型,只有最后的异步IO模型是异步IO操作,同时我们可以得出另外一个结论:阻塞和非阻塞都是针对同步的第一阶段而言的,对于异步IO根本不存在异步阻塞和异步非阻塞一说!因为只有这样才能和POSIX定义的IO模型匹配!
(上图很形象表明四种IO第一二阶段的阻塞情况)
关于Unix的IO模型细节这里不继续介绍了,本文的重点是通过Unix的IO模型去引入和理解Java中的IO模型,感兴趣的读者推荐阅读《Unix环境高级编程》和《Unix网络编程》
讲完Unix下的IO模型,下面来看下Java里面是怎么实现这些IO模型特性的:
BIO:Java通过BIO去实现Unix中的阻塞IO,也是Java中用的比较多的一种IO模式
NIO:Java通过NIO去融合Unix中的非阻塞IO和IO多路复用模型,因为对于单个套接字读写的非阻塞IO通常是没太大意义的,我们常常通过在多个套接字读写中加入非阻塞特性让程序发挥更大的效能。
NIO-Reactor:Java可以通过在NIO基础上实现Reactor机制来发挥多CPU的性能
AIO:Java通过AIO来实现Unix中的异步IO,其中AIO常通过Future或者Handle进行异步操作,后者被称为Proactor操作
下面逐个讲解这几种IO模型以及相关实例:
BIO:在JDK1.4,也就是NIO机制出来之前,我们网络编程都是通过BIO实现的,也就是服务器通过创建一个ServerSockt监听来自客户端的Socket,从Socket中获取InputStream,调用read()把数据读入一个缓冲数组中,下面是BIO的一个实例:
服务器:
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class BioServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket=new ServerSocket();
serverSocket.bind(new InetSocketAddress(8080));
while (true){
Socket socket=serverSocket.accept();
if (socket!=null) System.out.println("connected but not read");
InputStream inputStream=socket.getInputStream();
inputStream.read();
System.out.println("after read");
}
}
}
客户端:
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.Scanner;
public class BioClient {
public static void main(String[] args) throws IOException {
Socket socket=new Socket();
socket.connect(new InetSocketAddress(8080));
OutputStream outputStream=socket.getOutputStream();
//outputStream.write();
while (true){
Scanner scanner=new Scanner(System.in);
scanner.nextLine();
outputStream.write(1);
}
}
}
BIO有这样几个特征:首先它是调用阻塞的,大家可以测试一下启动这两个程序,如果clien不输入任何信息,那么服务器是不会输出read()调用之后的“after read”的,其次就是他是面向流的,这和NIO的面向缓冲区有很大的不同,Java中的流就是一串连续不断的数据集合,就像尼加瓜拉瀑布倾泻的流水,而且你读写必须是单向的。
关于BIO没有太多好说,因为大家接触Java的时候基本就是用BIO来实现各种IO操作
NIO:
NIO可以说是融合了Unix中非阻塞IO模型和多路复用模型的主要特性,他由三部分组成,分别是:Buffer(缓冲区),Channel(通道),Selector(选择器),NIO相对BIO有这样几个特性,首先:他是非阻塞的,就是说他不会像BIO那样阻塞于一个IO操作,如果当前IO操作的源数据没有准备好,会返回一个错误,请求进程接受到错误返回之后就会做别的事情然后等待发起下一次轮询,这和Unix中的非阻塞IO模型基本一致;其次:他的读写是面向缓冲区而不是面向流的,缓冲区就是刚刚提到的Buffer,这样带来的最大优势就是允许我们选择从什么地方开始读写;最后:他是多路复用而且是事件驱动的,关于这个是NIO的精髓,下面详细讲讲:
首先讲讲那三个NIO的最主要组件:
Channel:
也就是通道,可以类比成BIO中的Stream(流),只不过流是单向的,要么只能读(inputstream),要么只能写(outputstream),而Channel是双向的,既可以读也可以写,NIO中主要的Channel有:
FileChannel 通向文件的通道
SocektChannel 通向socket的通道
ServerSocektChannel 通向ServerSocekt的通道
Buffer:
Buffer就是数据源头(比如client的socket或者一个file)传输数据到通道必须经过的一个容器,如下图所示:
Buffer的用法是这样的:
首先我们需要分配空间,用比如ByteBuffer.allocate(1024)这样的函数,然后绑定到一个通道中,每次需要写如数据到Buffer中时调用channel.read(buffer)或者buffer.put(...),如果我们需要从中读取的话需要使用flip函数,然后读取完之后用clear方法或者compact方法复原,如果要从Buffer中读取数据到通道的话要使用channel.write(buffer),或者用get从缓冲区中读取数据,
大致用法讲完,我们细看一下Buffer的结构:
Buffer可以理解成一个列表,他通过几个变量:capacity,position,limit,mark来维护这个列表:
capacity:容量,表示这个缓冲区的总长度
position:下一个要操作的数据元素的位置
limit:下一个不可操作的元素的位置,小于等于容量capacity
mark:记录position的前一个位置,默认是-1
开始时position的值为0,limit和capacity的值为我们设定的长度,mark为-1,如果我们将n个字节数据写入Channel的通信信道的话,那么我们的position变为n,limit和mark值不变,这就是向Buffer进行写的一个过程,如果我们需要读的话要调用flip函数,这时position变回0,limit变为n,也就是最后一个写入字节的下一个元素位置,这时我们就可以把数据从Buffer读出写入到Channel了,之后,如果我们调用clear方法,position重新被设为0,limit设置为capacity,这时就相当于数据已经被遗忘了,当然他还存在于Buffer中,但是我们已经没有变量去获知他的边界了
Selector:
选择器可以看成是一个多路复用器(mux),每次我们对一个通道操作前要先将其注册到选择器上,注意此时的通道一定要调用configureBlocking(false)来设定为非阻塞,注册完之后会返回一个SelectironKey对象,这个对象可以看成是一个索引,或者说是一把钥匙,可以用这把钥匙去查找到对应的选择器和通道,以及他是对什么事件敏感的
贴一个NIO的demo:
服务器:
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
public class Server {
public static void main(String[] args) throws IOException {
Selector selector=Selector.open();
ByteBuffer buffer=ByteBuffer.allocate(1024);
ServerSocketChannel socketChannel=ServerSocketChannel.open();
socketChannel.socket().bind(new InetSocketAddress(8080));
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Iteratoriterator=selector.selectedKeys().iterator();
while (iterator.hasNext()){
SelectionKey key=iterator.next();
//System.out.println("able: "+key.isAcceptable()+" "+key.isReadable()+' '+key.isWritable()+' '+key.isValid());
if (key.isAcceptable()){
handleAccept(key);
}
if (key.isReadable()){
handleRead(key);
}
if (key.isWritable()&&key.isValid()){
handleWrite(key);
}
}
}
}
public static void handleAccept(SelectionKey key) throws IOException {
ServerSocketChannel serverSocketChannel= (ServerSocketChannel) key.channel();
SocketChannel sc=serverSocketChannel.accept();
// if (sc==null){
// System.out.println("sc is null");
// }
// else {
// System.out.println("sc is not null "+sc.toString());
// }
if (sc!=null) {
System.out.println("connected from"+sc.getRemoteAddress());
sc.configureBlocking(false);
sc.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
}
public static void handleRead(SelectionKey key) throws IOException {
SocketChannel sc= (SocketChannel) key.channel();
ByteBuffer buffer=(ByteBuffer)key.attachment();
long bytesread=sc.read(buffer);
int i=0;
while (bytesread>0){
buffer.flip();
while (buffer.hasRemaining()){
System.out.print((char)buffer.get());
}
i++;
System.out.println();
buffer.clear();
bytesread=sc.read(buffer);
}
if (bytesread==-1){
sc.close();
}
}
public static void handleWrite(SelectionKey key) throws IOException {
ByteBuffer buffer= (ByteBuffer) key.attachment();
buffer.flip();
SocketChannel sc= (SocketChannel) key.channel();
while (buffer.hasRemaining()){
sc.write(buffer);
}
buffer.compact();
}
}
客户端:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Scanner;
import java.util.concurrent.TimeUnit;
public class Client {
public static void main(String[] args) throws IOException, InterruptedException {
ByteBuffer buffer=ByteBuffer.allocate(1024);
Selector selector=Selector.open();
SocketChannel sc=SocketChannel.open();
sc.configureBlocking(false);
sc.connect(new InetSocketAddress("127.0.0.1",8080));
if (sc.finishConnect()){
int i=0;
while (true){
TimeUnit.SECONDS.sleep(1);
Scanner scanner=new Scanner(System.in);
String info=scanner.nextLine();
buffer.clear();
buffer.put(info.getBytes());
buffer.flip();
while (buffer.hasRemaining()){
System.out.println(buffer);
sc.write(buffer);
}
}
}
}
}
在大概讲完NIO的特征之后,简单说一下NIO是怎么工作的:
首先,当前线程会创建一个选择器Selector,并且把一个ServerSocketChannel注册到上面,设定他是对accept事件敏感的,但有一个socketchannel和他连接时就会触发accept事件,此时我们就把socketchannel注册到选择器上,设定他是对读写事件敏感的,之后如果socketchannel上发生了读写事件的话,多路复用器就会感知到,从而做出相应的应对
关于NIO详细可以看这一篇 https://blog.csdn.net/u011381576/article/details/79876754
关于reactor模式和aio会在之后细讲。