网络IO的本质是对socket的读取。
在网络IO的过程中,有两个重要角色,分别是系统内核和用户进程。首先要等系统内核准备好数据,然后将数据从系统内核拷贝至用户进程空间,这样才算完成了一次IO。
如果在系统内核没有准备好数据时,用户IO线程在此阻塞住了,这就是阻塞IO。如果用户线程没有阻塞,而是返回一个结果,标识数据还没有准备好,这种就是非阻塞IO。
Unix中提出了5种网络IO模型:
- 阻塞IO(Blocking IO)
- 非阻塞IO(Non-blocking IO)
- 多路复用IO(Multiplexing IO)
- 信号驱动IO(Single-driven IO)
- 异步IO(Asychronous IO)
其中,linux中并没有实现真正的异步IO,而信号驱动IO使用也比较少。本文主要对前三种IO进行介绍。
BIO
BIO,也就是阻塞式io模型,是最常见的网络io模型,Java经典的io流即是这种模型。
单线程BIO
服务端:
public class SocketServer {
public static void main(String[] args) throws Exception {
// 监听指定的端口
int port = 8080;
ServerSocket server = new ServerSocket(port);
// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
Socket socket = server.accept();
// 建立好连接后,从socket中获取输入流
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println("get message from client: " + br.readLine());
DataOutputStream ds = new DataOutputStream(socket.getOutputStream());
ds.writeUTF("Hello Client,I get the message.");
br.close();
ds.close();
socket.close();
server.close();
}
}
客户端:
public class SocketClient {
public static void main(String args[]) throws Exception {
// 要连接的服务端IP地址和端口
String host = "127.0.0.1";
int port = 8080;
// 与服务端建立连接
Socket socket = new Socket(host, port);
// 建立连接后获得输出流
DataOutputStream dw = new DataOutputStream(socket.getOutputStream());
String message = "你好";
dw.writeUTF(message);
//通过shutdownOutput告诉服务器已经发送完数据,后续只能接受数据
socket.shutdownOutput();
DataInputStream di = new DataInputStream(socket.getInputStream());
System.out.println("get message from server: " + di.readUTF());
di.close();
dw.close();
socket.close();
}
}
服务端启动之后,通过accept方法进行阻塞等待,直到收到客户端的请求。
接着服务端代码继续执行,到br.readLine()处继续阻塞,除非客户端告诉服务端请求已经发送完成。但是如何知道客户端的请求已经发送完了呢?
有两种方法,一种是让客户端发送完请求之后关闭socket(或者关闭输出流),但这样一来客户端就无法接收到服务端的返回消息了。另外一种就是让客户端调用shutdownOutput方法,告知服务端已经写完成,就如上述例子中的一样。但是任然有一个问题,那就是客户端无法再继续发送请求。除非重新打开一个新的socket来通信,但这显然是非常耗费性能的。
因此需要一个约定的字符或短语来当做消息发送完成的标识。
在下面的例子中,客户端给服务端发送两条消息。约定换行符(\n)为一次请求结束标识,byebye为通信结束标识。
public class SocketClient {
public static void main(String args[]) throws Exception {
// 要连接的服务端IP地址和端口
String host = "127.0.0.1";
int port = 8080;
// 与服务端建立连接
Socket socket = new Socket(host, port);
// 建立连接后获得输出流
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String message1 = "hello";
String str = ", nice to meet you\n";
bw.write(message1);
bw.write(str);
String message2 = "byebye\n";
bw.write(message2);
bw.flush();
DataInputStream di = new DataInputStream(socket.getInputStream());
System.out.println("get message from server: " + di.readUTF());
di.close();
bw.close();
socket.close();
}
}
服务端通过while循环来读取客户端发来的请求。换行符作为一次请求结束标识,当没有读到换行符时就在read处阻塞等待(如接收到hello时),直到接收到一次完整的请求。
public class SocketServer {
public static void main(String[] args) throws Exception {
// 监听指定的端口
int port = 8080;
ServerSocket server = new ServerSocket(port);
// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
Socket socket = server.accept();
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
DataOutputStream ds = new DataOutputStream(socket.getOutputStream());
String line;
StringBuilder sb = new StringBuilder();
while (!(line = br.readLine()).equals("byebye")) {
sb.append(line);
System.out.println("get message from client: " + sb.toString());
ds.writeUTF("Hello Client,I get the message.");
sb.delete(0, line.length());
}
br.close();
ds.close();
socket.close();
server.close();
}
}
上面的例子中,服务端只能处理一次客户端请求,处理完成之后服务端关闭。为了使服务端能够保持运行状态,可通过while循环持续接收客户端请求:
public class SocketServer {
public static void main(String[] args) throws Exception {
// 监听指定的端口
int port = 8080;
ServerSocket server = new ServerSocket(port);
// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
Socket socket;
while (true) {
socket = server.accept();
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
DataOutputStream ds = new DataOutputStream(socket.getOutputStream());
String line;
StringBuilder sb = new StringBuilder();
while (!(line = br.readLine()).equals("byebye")) {
sb.append(line);
System.out.println("get message from client: " + sb.toString());
ds.writeUTF("Hello Client,I get the message.");
sb.delete(0, line.length());
}
br.close();
ds.close();
}
}
}
虽然能够持续接收客户端请求,但是服务端只有一个线程来监听请求并处理。如果同时有多个客户端请求,服务端每次只能处理一个。这是一种单线程阻塞模式。
多线程BIO
单线程阻塞模式的缺点显然是非常明显的。为了提高服务端处理能力,可以对服务端进行改造:
public class SocketServer {
public static void main(String[] args) throws Exception {
// 监听指定的端口
int port = 8080;
ServerSocket server = new ServerSocket(port);
// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
while (true) {
final Socket socket = server.accept();
new Thread(new Runnable() {
public void run() {
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
try {
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
DataOutputStream ds = new DataOutputStream(socket.getOutputStream());
String line;
StringBuilder sb = new StringBuilder();
while (!(line = br.readLine()).equals("byebye")) {
sb.append(line);
System.out.println("get message from client: " + sb.toString());
ds.writeUTF("Hello Client,I get the message.");
sb.delete(0, line.length());
}
br.close();
ds.close();
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
}).start();
}
}
}
服务端通过主线程循环监听客户端发来的请求,并在接收到请求时启动另一个线程来处理请求。这样主线程只要负责监听客户端请求,而不用去处理复杂业务逻辑,因此可以快速响应客户端请求。
但是,主线程在监听客户端请求时仍然是阻塞式的,也就是说当socket上的数据没有准备好时,线程是阻塞住的。
这种模式就是多线程阻塞模式,与单线程阻塞模式相比,提升了服务端处理并发请求的能力。
在BIO模型中,对每一个客户端连接,默认都需要创建一个线程去处理io操作和业务逻辑。而在处理io操作和业务逻辑的时候,有可能会因为等待io或其他资源而发生阻塞。当并发量较大时,服务器就会创建很多线程来处理客户端连接。而大量线程会消耗大量资源,给服务器带来很大的压力,甚至导致服务器瘫痪都是有可能的。
非阻塞IO
默认创建的socket都是阻塞的,非阻塞IO要求将socket设置成非阻塞的。这样,在系统内核中的数据还没有准备好时,也会返回一个错误码,应用程序可以继续执行,不会阻塞。可以每隔一段时间来询问一次,看看数据有没有准备好。这样的模式就是同步非阻塞的。
注意:这里的非阻塞IO(Non-blocking IO)并不是java中的NIO库。
多路复用IO
在多路复用IO模型中,通过一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才会去处理。
通过一个线程管理多个socket,而无需建立新的进程或线程,大大减少了资源占用。
这种机制可以让单个进程或线程具有处理多个 IO 事件的能力,又被称为 Event Driven IO,即事件驱动 IO。
Java中的NIO库和Linux的select,poll,epoll都是多路复用IO的实现。
Java NIO
Java NIO是Java1.4引入的非阻塞IO模型。NIO基于事件驱动的思想,采用了Reactor反应模式。通过一个线程来管理所有的Socket通道,通过轮询的方式,也就是Selector机制来查询io事件(连接/读/写),获取到感兴趣的事件就对其进行相应处理。工作流程示意图如下:
直接看下面的demo。
客户端代码:
public class NoBlockClient {
public static void main(String[] args) throws IOException {
// 1. 获取通道
SocketChannel socketChannel;
socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 6666));
//1.1 切换成非阻塞模式
socketChannel.configureBlocking(false);
// 2. 发送一个文件给服务端
FileChannel fileChannel = FileChannel.open(Paths.get("/xxx/temp.pdf"), StandardOpenOption.READ);
// 3.要使用NIO,有了Channel,就必然要有Buffer,Buffer是与数据打交道的
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 4.读取本地文件,发送到服务器
while (fileChannel.read(buffer) != -1) {
// 要从buffer里读数据则要切换到读模式
buffer.flip();
// 从buffer获取数据写到socket通道中
socketChannel.write(buffer);
// 清空buffer,以便下次循环重新从fileChannel中读数据
buffer.clear();
}
// 5. 关闭流
fileChannel.close();
socketChannel.close();
}
}
服务端代码:
public class NoBlockServer2{
public static void main(String[] args) throws IOException {
// 1.获取通道
ServerSocketChannel server = ServerSocketChannel.open();
//1.1 切换成非阻塞模式
server.configureBlocking(false);
// 3. 绑定链接
server.bind(new InetSocketAddress(6666));
// 4. 获取选择器
Selector selector = Selector.open();
// 4.1 将通道注册到选择器上
server.register(selector, SelectionKey.OP_ACCEPT);
FileChannel outChannel = FileChannel.open(Paths.get("test.pdf"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
// 5. 轮询获取选择器上已就绪的IO事件
while (selector.select() > 0) {
// 获取当前选择器所有注册的选择键(已就绪的监听事件)
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
// 接收事件就绪
if (selectionKey.isAcceptable()) {
// 获取客户端连接
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(false);
// 注册到选择器上(监听读就绪事件)
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) { // 读事件就绪
SocketChannel socketChannel = (SocketChannel)selectionKey.channel();
// 读取数据
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (socketChannel.read(buffer) > 0) {
// 从buffer中读数据写到outChannel通道
buffer.flip();
outChannel.write(buffer);
// 读完切换成写模式,能让管道继续读取文件的数据
buffer.clear();
}
}
// 已经处理过的事件,应该取消掉
iterator.remove();
}
}
}
}
linux中的多路复用
在linux中,select,poll和epoll都是采用的多路IO复用机制。
select、poll
在select/poll中,进程将一个或多个fd(文件描述符)传递给select或poll系统调用,并且阻塞在select或poll方法上(注意:这里的阻塞并不是IO阻塞,而是系统调用的阻塞)。
同时,kernel(linux内核)会侦测所有的fd是否处于就绪状态,如果有任何一个fd就绪,select或poll就会返回,这个时候用户进程再调用recvfrom,将数据从内核缓冲区拷贝到用户进程空间。
以select为例,其调用过程如图所示:
核心流程描述如下:
while true {
// 在select上阻塞
select(streams[])
// 无差别轮询
for i in streams[] {
read until unavailable
}
}
这里有个问题,select有返回仅仅说明有I/O事件发生了,但却并不知道具体是哪个流(可能有一个或多个,甚至全部),只能无差别轮询所有流,找出实际就绪的事件进行操作。在这个过程中,需要O(n)的时间复杂度,同时处理的流越多,每一次无差别轮询时间就越长。
epoll
不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知到用户程序,然后用用户程序进行处理,时间复杂度时O(1)。
描述如下:
// 事先调用epoll_ctl注册感兴趣的事件到epollfd
while true {
// 返回触发注册事件的流
active_stream[] = epoll_wait(epollfd)
// 无须遍历所有的流
for i in active_stream[] {
read or write till
}
}
阻塞与同步
阻塞,非阻塞,同步,异步,这几个词在网络通信模型中常常是关联着说的,两两组合有四种模式。
简单点理解,阻塞和非阻塞是针对调用方的,同步和异步是针对被调用方的。举个简单栗子:
小明同学通过chrome浏览器下载一个文件。
- 小明在下载文件的时候不做其他事情,一直等着下载进度条完成。这种情况就是阻塞同步。
- 小明在下载文件的时候不做其他事情,但不再盯着下载进度条,而是等浏览器下载完成后通过提示音进行通知。这种情况属于阻塞异步。
- 小明点击下载按钮之后,就去做其他事情了。然后时不时看看下载进度条有没有完成。这种情况属于非阻塞同步。
- 小明点击下载按钮之后,就去做其他事情了。浏览器下载完成后通过提示音通知小明下载完成。这种情况属于非阻塞异步。
从上面的例子中可以看出,是否阻塞取决于在拿到结果之前能不能做其他事情,是否同步取决于结果是主动获取的,还是通过回调的方式来异步通知的。
从操作系统来看,所有的系统I/O都分为两个阶段,等待就绪和操作。具体来说,等待就绪是指等待系统空间的数据准备就绪,操作是指将系统空间内准备就绪的数据复制到用户空间。举例来说,BIO中的read方法,当数据还没有准备就绪时,就会一直等待着数据就绪,此时线程一直在read方法上阻塞,不能继续往下执行。而NIO中的read方法,当数据还没有准备就绪时,直接返回一个值,而不会在这里阻塞等待。
参考资料
[1]https://www.zhihu.com/question/29005375
[2]https://blog.csdn.net/u014467070/article/details/76977262