这篇文章先从阻塞与非阻塞,同步与异步之间的定义和关系说起,然后探讨liunx下的5种IO模型,支持非阻塞IO的select/poll/epoll系统调用的基本原理,然后通过Java代码搭建bio方式的服务端,改进服务器在并发场景下bio多线程和线程池的实现方式,最后介绍Java nio来实现一个服务器和多个客户端对话。
阻塞与非阻塞,同步与异步
如果从程序调用来讲,阻塞是指我们执行一个函数调用不能立即得到返回值,而依赖于其他外部的事件完成,比如说网络包的到达、IO操作的完成,否则的话就是非阻塞。其根本原因是cpu执行指令的速度远大于网络操作、IO操作的速度,这种速度差导致了程序运行的阻塞。
同步与异步是从另一个角度来看程序的运行,它是指在程序运行的过程中遇到阻塞事件是否能够让出cpu去处理其他的事情,如果能的话,就是异步的,如果不能的话就是同步的。
从这个角度来看,阻塞与非阻塞,同步与异步是一个事情的两个方面,以IO为对象来说,阻塞IO必然导致着同步,而非阻塞IO也必然导致着异步。
liunx的5种网络IO模型
上图显示了liunx的5种IO模型,其中包括了读的两个执行过程:阶段1)从IO设备读取数据到内核空间(wait for data)和 阶段2)从内核空间复制数据到用户空间(copy data from kernel to user)
* 阻塞IO,不论是阶段1还是阶段2都是处于阻塞状态
* 非阻塞IO,不断的检查数据是否准备好,如果没有直接返回一个错误码,第二阶段也是阻塞的
* IO多路复用,可以看到IO多路复用阶段1和阶段2都是阻塞的,但是不同的是,IO多路复用在阶段1可以同时处理多个文件描述符,这使得在原本用多个线程完成的任务可以在一个线程中完成,但从流程上看,IO多路复用还是阻塞的IO,并且是同步IO。
* 信号量驱动的IO使用信号来通知数据是否准备好,当数据准备好了才通知线程继续处理,所以阶段1是非阻塞的,但是阶段2仍然是阻塞的过程。
* 异步IO,异步IO不论是阶段1还是阶段2都是非阻塞的,调用异步IO接口后,函数会立即返回,当数据成功拷贝到用户空间后再通知用户程序继续处理。
从上图看来,前四种IO模型在第2阶段的处理都是一样的,而在第1阶段的处理方式不同。
Java nio的实现是基于IO多路复用实现的,它实现了在同一线程下处理多个文件描述符的功能,这种功能使得应用程序避免了因为线程切换而导致的性能损失。IO多路复用的实现需要系统调用的支持,例如select/poll/epoll,因为本文重点不在这里,所以不详述,可以参考文末的参考文献。
基础BIO Server
从这里开始,我们开始用Java API来写一个server和client通信,以此来探索Java对不同io方式的支持。
首先是最基础的bio,使用ServerSocket API构建一个简单的服务器,基本的过程如下
* 初始化ServerSocket
* 绑定ServerSocket到一个InetSocketAddress(ip,port)
* 调用accept()方法获取客户端socket,这个方法是阻塞的
* 通过client socket获取对应的inputStream和outputStream读取客户端的信息,并通过outputStream写回信息
主要的代码实现如下
ServerSocket serverSocket=null;
try {
serverSocket=new ServerSocket();
serverSocket.bind(new InetSocketAddress("127.0.0.1", 8888));
while(true) {
Socket client=serverSocket.accept();
client.setSoTimeout(1000);//客户端read要在1秒内返回,不然认为是IO异常
System.out.println("get connection:"+client);
InputStream input=client.getInputStream();
BufferedReader reader=new BufferedReader(new InputStreamReader(input));
BufferedWriter writer=new BufferedWriter(new OutputStreamWriter(client.getOutputStream()));
try {
while(true) {
String clientWord= reader.readLine();
client.sendUrgentData(0);//发送心跳包,如果客户端断开连接,则出现IO异常
if(clientWord==null)
continue;
System.out.println(client.getRemoteSocketAddress()+":"+clientWord);
if("wenqi".equals(clientWord)) {
writer.write("welcome admin wenqi!\n");
writer.flush();
}else {
writer.write("welcome client "+clientWord+"!\n");
writer.flush();
}
}
}catch(IOException e) {
reader.close();
writer.close();
client.close();
System.out.println("断开连接");
}
}
} catch (IOException e) {
e.printStackTrace();
}
在实际测试的过程中,有一个有趣的问题,如何判断客户端已经断开的连接,不管是正常的,还是不正常的服务端都要正确的处理,在上面的代码我们做的两种处理,一是设置client socket的setSoTimeout(time),这个api的作用是让client的read操作阻塞指定的时间,如果超过指定的时间就抛出SocketException(继承自IOException),强制断开与客户端的连接,这是服务端主动断开连接。
二是在实际情况下,如果客户端断开了连接,服务端并不知道客户端已经断开了连接,并且会一直读到null,这时候服务端在每次读取信息之前就要检查客户端是否还处于连接的状态,我们用sendUrgentData()去判断客户端是否处于连接的状态,如果不是则会抛出IOException,这样,服务端就知道了客户端已经断开了连接,可以关闭连接,不然的话,服务器会一直处于忙等的状态,并且无法处理其他的连接请求。
另外需要注意的是每次用write发送信息的时候必须用\n标注一行信息的结束,并且用flush刷新才会发送过去。
我们在每个客户端的逻辑中连续给服务端发送了两条信息,并且开启了10个线程的客户端,每个客户端主要逻辑如下:
Socket client=new Socket("127.0.0.1", 8888);
OutputStream out=client.getOutputStream();
InputStream input=client.getInputStream();
BufferedWriter writer=new BufferedWriter(new OutputStreamWriter(out));
BufferedReader reader=new BufferedReader(new InputStreamReader(input));
writer.write("wenqi\n");
writer.flush();
String clientWord=reader.readLine();
System.out.println("server response to "+num+"-"+clientWord);
writer.write("good-"+num+"\n");
writer.flush();
String clientWord2=reader.readLine();
System.out.println("server response to "+num+"-"+clientWord2);
writer.close();
reader.close();
client.close();
基础bio的全部代码可以点击这里
基于线程池的BIO Server
上面的Server可以顺利的处理10个客户端的连接,这得益于我们对服务器和客户端不同状态的正确处理,单从整个过程来看,我们必须串行的逐个处理每个请求,这在并行大行其道的今天,没有让我们用到多处理器并发的优势,在这一节,我们将接受客户端连接放在主线程里,把对每个client socket的读写任务放到每个子线程中去运