从代码视角看IO方式演进

前言

IO是输入输出,写代码离不开IO,磁盘IO、网络IO。常见的网络IO模型有BIO、NIO、AIO三种。下面以代码视角描述Java中的IO方式的演进,以及介绍同步、异步、阻塞、非阻塞的概念。

BIO

最简单的Socket通信模式就是服务端构建一个ServerSocket类,调用accept方法等待客户端连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class BIOServer {
public static void main(String[] args) {
try {
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("Waiting for client...");
//执行到accept开始阻塞等待客户端连接
Socket socket = serverSocket.accept();
System.out.println("Client connected.");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println("Server received:");
String line;
while ((line = bufferedReader.readLine()) != null) {
//不断输出用户传输的数据
System.out.println(line);
//收到客户端传输的exit则退出
if (line.equals("exit")) {
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class BIOClient {
public static void main(String[] args) {
try {
Socket socket = new Socket("127.0.0.1", 8888);
String input;
while (true) {
input = (new BufferedReader(new InputStreamReader(System.in))).readLine();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
writer.write(input + "\n");
writer.flush();
}


} catch (IOException e) {
e.printStackTrace();
}
}
}

这种方式,服务端只能处理客户端的一次连接,当客户端输入exit结束连接后,服务端也将退出程序。

为了解决上述问题,我们可以使用while循环,让服务端不断的接受客户端连接,在处理完一个客户端的连接时,可以马上等待接收处理下一个客户端的连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class BIOServer2 {
public static void main(String[] args) {
try {
ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
System.out.println("Waiting for client...");
//执行到accept开始阻塞等待客户端连接
Socket socket = serverSocket.accept();
System.out.println("Client connected.");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println("Server received:");
String line;
while ((line = bufferedReader.readLine()) != null) {
//不断输出用户传输的数据
System.out.println(line);
//收到客户端传输的exit则退出
if (line.equals("exit")) {
break;
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

同样,这种方式也存在一个问题,服务器一次只能接收一个客户端的连接,要想处理下一个客户端连接的时候,必须处理完毕当前客户端的请求。也就是说服务器支持的最大并发数为1,一次只能处理一个连接。

在这里我们可以采用开启线程的方式来处理连接建立后的业务逻辑,while循环不断accept客户端连接,连接到来之后的处理交给线程去处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class BIOServer3 {
public static void main(String[] args) {
try {
ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
System.out.println("Waiting for client...");
//执行到accept开始阻塞等待客户端连接
Socket socket = serverSocket.accept();
System.out.println("Client connected.");
new Thread(() -> {
BufferedReader bufferedReader;
try {
bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println("Server received:");
String line;
while ((line = bufferedReader.readLine()) != null) {
//不断输出用户传输的数据
System.out.println(line);
//收到客户端传输的exit则退出
if (line.equals("exit")) {
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

这种方式虽然可以大大提高吞吐量,但是每一个客户端连接都需要创建线程来处理,非常消耗系统资源。即使我们将new Thread改成线程池ThreadPoolExecutor来替换,也有可能被阻塞任务积压导致资源耗尽。

NIO

NIO是多路复用IO,对比BIO需要采用多线程来实现高吞吐而言,NIO只需要一个线程就能处理成百上千个Socket连接。Socket从本质上讲,其实是一个文件,通过文件描述符FD,我们可以知道某个Socket的状态,例如连接是否完成,数据是否准备好。我们只需要使用一个线程来遍历所有Socket的状态,即可对Socket进行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class NIOServer {
public static void main(String[] args) {
try {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(new InetSocketAddress(8888));
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
//等待SelectionKey事件到来
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
//删除key,否则下一次还将会读取到
iterator.remove();
SelectableChannel channel = selectionKey.channel();
if (selectionKey.isValid() && selectionKey.isAcceptable()) {
ServerSocketChannel serSocketChannel = (ServerSocketChannel) channel;
SocketChannel socketChannel = serSocketChannel.accept();
socketChannel.configureBlocking(false);
//取到了客户端的socketChannel后,注册读取事件到Selector
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isValid() && selectionKey.isReadable()) {
//可读SelectionKey到达,读取数据
SocketChannel socketChannel = (SocketChannel) channel;
ByteBuffer buffer = ByteBuffer.allocate(128);
socketChannel.read(buffer);
byte[] data = buffer.array();
String msg = new String(data);
System.out.println(msg);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}

}
}

Java原生的NIO写法比较混乱,不够逼格,我们可以使用Netty来构建高性能的Socket服务器。

同步与异步

举两个例子,1、前端Html页面中发起Ajax请求,在Ajax的回调函数中执行页面局部刷新;2、客户端控件设置点击事件,setOnClickListener(),在用户点击按钮后执行一些业务逻辑。这两种情况,主线程发起Ajax后,或者设置点击事件后,主线程可以无需关注后续操作,回调函数中的操作交给异步线程处理。这种采用观察者设计模式的回调方式,就叫做异步。而同步,则是指主线程需要处理后续操作,要么采用阻塞方式等着处理,要么采用轮询方式判断是否可以处理。同步的这两种情况我们叫做同步阻塞IO和同步非阻塞IO。

同步阻塞IO

先看BIOServer这个类,有两个地方涉及到阻塞,一个是serverSocket.accept()方法等待客户端连接的时候,一个是bufferedReader.readLine()方法等待客户端数据的时候。显而易见,阻塞的意思就是指程序执行到这个位置后,停止不动了,需要等待相关资源。

同步非阻塞IO

再看NIOServer这个类,只需要启动一个while循环,不断从Selector中筛选所有Socket的状态,应用线程可以在Socket连接建立后、缓冲区可读后进行处理,而无需阻塞等待。只有selector.select()获取不到数据的时候才被阻塞。

其他

1、NIO多路复用中的Selector扫描各个Socket状态,在select、poll中采用的内核线程轮询所有Socket的方式,而epoll采用的则是事件驱动的中断方式,缓冲区中有数据、则给CPU发出一个中断信号。
2、Tomcat底层可以采用NIO的多路复用,数据到来后,采用的是线程池的方式处理HttpServlet。Servlet的标准规范中,read body blocking,但是read head non-blocking。其实就是采用NIO读取到head头后,将socket交给线程处理,而线程中读取body数据是阻塞的。