什么是I/O
学过Linux的都知道这么一句话:在Linux系统中,一切皆文件。在网络信息交换的过程中,文件即是一串二进制流。而我们对于二进制流数据的收发操作,就是I/O操作。在实际应用中,我们一般对流有比如read/write等操作,那么如何知道我们操作的是哪个流(文件)呢?实际上是由操作系统内核创建文件描述符(File Descriptor,FD)来标识的,一个FD就是一个非负整数,实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核会向进程返回一个文件描述符。所以对这个流(文件)的操作其实就是对这个FD的操作。我们创建一个Socket,通过系统内核的调用会返回一个FD,接下来对Socket的操作就会转化为对这个FD或者说对流(文件)的操作。这是一种分层和抽象的思想。
网络I/O交互流程
通常用户进程的一次完整的I/O交互流程分为两个阶段,首先是磁盘到内核空间(操作系统)缓冲区,再由内核空间到用户空间(应用程序)缓冲区。如下图:
对于一次read操作来说,来自内核空间的进程发起一次I/O系统调用后,内核会先看缓冲区有没有对应数据,如果没有,需要从磁盘中去读取。数据从磁盘到内核的I/O一般会比较慢,需要等待较长时间。读取完成后放入内核缓存区。再从内核缓冲区直接复制到用户缓冲区。通常我们说的I/O是指网络I/O和磁盘I/O,网络I/O可分为以下两个阶段:
(1)等待数据阶段:等待网络数据到达网卡,然后将数据读取到内核缓冲区。
(2)拷贝数据阶段:将内核缓冲区数据,拷贝到用户进程。
五种I/O模型
如果想要提高I/O效率,需要将等待时间降低,这个等待时间指上面两个步骤的(1)。因此发展出来五种I/O模型,分别是:
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)
其中,前四种被称为同步I/O,下面对每一种I/O模型进行详细分析。
BIO(阻塞IO)
默认情况下,Linux的所有Socket操作都是阻塞的,流程如下:
当用户进程调用了recvfrom这个系统调用,内核就开始了I/O的第一个阶段:准备数据。对于网络I/O来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞,当数据准备好时,它就会将数据从内核拷贝到用户内存,然后返回结果,用户进程才解除阻塞的状态,重新运行起来。几乎所有的开发者第一次接触到的网络编程都是从listen()、send()、recv()等接口开始的,这些接口都是阻塞型的。阻塞I/O模型的特性总结如下表所示。
java实现代码:
public class BioSocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
final Socket socket = serverSocket.accept(); //阻塞方法
System.out.println("有客户端连接了。。");
//加强版BIO,缺点:一个客户端读数据需要一个线程去处理。
new Thread(() -> {
try {
handler(socket);
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
/**
* 读取数据并返回
* @param socket
* @throws IOException
*/
private static void handler(Socket socket) throws IOException {
byte[] bytes = new byte[1024];
System.out.println("准备read。。");
//接收客户端的数据,阻塞方法,没有数据可读时就阻塞
int read = socket.getInputStream().read(bytes);
System.out.println("read完毕。。");
if (read != -1) {
String message = new String(bytes, 0, read);
System.out.println("接收到客户端的数据:" + message);
}
//阻塞方法
socket.getOutputStream().write("服务器已收到".getBytes());
socket.getOutputStream().flush();
}
}
NIO(非阻塞IO)
linux系统下,可以通过设置Socket使其变为非阻塞。对非阻塞Socket执行读操作时,流程如下:
当用户进程发出read操作时,如果内核中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果,用户进程判断结果是一个error时,它就知道数据还没有准备好。于是它可以再次发送read操作,一旦内核中的数据准备好了,并且再次收到了用户进程的系统调用,那么它会马上将数据拷贝到用户内存,然后返回,非阻塞型接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。非阻塞I/O模型的特性总结如下表所示。
非阻塞模式Socket与阻塞模式Socket相比,不容易使用。使用非阻塞模式Socket,需要编写更多的代码,但是,非阻塞模式Socket在控制建立多个连接、数据的收发量不均、时间不定时,具有明显优势。
java实现代码:
public class NioServer {
public static void main(String[] args) throws Exception {
//创建ServerSocket通道,并设置为非阻塞,绑定本机端口
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(50111));
serverSocketChannel.configureBlocking(false);
while (true) {
//不会阻塞
final SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel != null) {
System.out.println("有客户端连接了。。");
new Thread(() -> {
try {
handler(socketChannel);
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
System.out.println("没有连接做其他事去了。。");
Thread.sleep(1000);
}
}
/**
* 读取数据并返回
* @param socketChannel
* @throws IOException
*/
private static void handler(SocketChannel socketChannel) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
//NIO与BIO的不同之处,如果没有数据还未到达内核缓冲区,则返回-1,当前线程可以去做其他事情,此为非阻塞。
//缺点:需要一直轮询去访问内核,数据是否已经准备好(网卡到内核缓存区这一阶段)
int readLength;
while ((readLength = socketChannel.read(byteBuffer)) == -1) {
System.out.println("数据未准备好,做其他事去咯。。。。");
}
String message = new String(byteBuffer.array(), 0, readLength, CharsetUtil.UTF_8.name());
System.out.println("接收到客户端的数据:" + message);
ByteBuffer writeBuffer = ByteBuffer.wrap("服务器已收到".getBytes("utf-8"));
socketChannel.write(writeBuffer);
}
}
I/O 多路复用( IO multiplexing)
IO 多路复用就是我们说的select,poll和epoll函数,流程如下:
多个进程的I/O可以注册到一个复用器(Selector)上,当用户进程调用该Selector,Selector会监听注册进来的所有I/O,如果Selector监听的所有I/O在内核缓冲区都没有可读数据,select调用进程会被阻塞,而当任一I/O在内核缓冲区中有可读数据时,select调用就会返回,而后select调用进程可以自己或通知另外的进程(注册进程)再次发起读取I/O,读取内核中准备好的数据,多个进程注册I/O后,只有一个select调用进程被阻塞。
多路复用I/O模型的特性总结如下表所示。
PS:看到NIO的示例代码,明白人都知道这样接收到连接后,为每个客户端创建一个线程去处理,如果有大量的并发连接,会非常的耗费CPU资源。那有没有什么好办法呢?有的,这里可以accept到一个客户端连接后,将这个FD放到一个数组里。
fdlist.add(connfd);
然后开一个新线程去轮询这个数组,调用NIO非阻塞的read()方法。
while(1) {
fdlist.forEach(fd -> {
if(read(fd) != -1) {
//接收到客户端的数据...
doRead();
}
});
}
这样一看,好像是实现了一个进程可以处理多个客户端连接。那这不就是I/O多路复用干的事吗?这只是对I/O多路复用的片面理解。当我们在 while 循环里通过用户进程去系统调用(类似拉模型,用户去拉取系统内核的FD信息),就好比你做分布式项目时在 while 里做 rpc 请求一样,是不划算的。
其实I/O多路复用这里是通过Reactor模式,通过主动推的事件模型,让系统内核去遍历这些FD,有准备就绪的数据,就主动推给用户进程去处理。这里就涉及到几个系统调用函数:select、poll和epoll。
select
select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理。细节如下:
(1)select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
(2)select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
(3)select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
select 的流程如下。
poll
poll 也是操作系统提供的系统调用函数,它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。
epoll
epoll 是select 和 poll的加强版,它解决了 select 和 poll 的一些问题。
(1)内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
(2)内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
(3)内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
NIO + I/O多路复用java代码如下:
/**
* 新版nio服务端 非阻塞IO + IO多路复用epoll
*/
public class NewNioServer {
public static void main(String[] args) throws Exception {
//创建ServerSocket通道,并设置为非阻塞,绑定本机端口
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//必须为非阻塞,否则会报错
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(50111));
//创建一个选择器Selector,
Selector selector = Selector.open();
//注册接收事件,即注册客户端的连接事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true){
//此为IO的多路复用机制,linux环境下用的是epoll函数,windows环境下用的是poll(windows系统无epoll)。
int select = selector.select();
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()){
SelectionKey key = it.next();
handle(key);
it.remove();
}
}
}
private static void handle(SelectionKey key) throws IOException {
if (key.isAcceptable()){
System.out.println("有客户端连接事件发生..." );
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(key.selector(),SelectionKey.OP_READ);
serverSocketChannel.close();
}else if (key.isReadable()){
System.out.println("读取客户端发送的数据:" );
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int length = socketChannel.read(byteBuffer) ;
if (length != -1){
System.out.println("服务端发来信息:" + new String(byteBuffer.array(), 0, length,"utf-8"));
}
key.interestOps(SelectionKey.OP_WRITE | SelectionKey.OP_READ);
}else if (key.isWritable()){
System.out.println("服务端写事件..." );
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer writeBuffer = ByteBuffer.wrap("服务器已收到".getBytes("utf-8"));
socketChannel.write(writeBuffer);
key.interestOps(SelectionKey.OP_READ);
}
}
}
信号驱动I/O模型
信号驱动I/O模型应用场景较少,这里就不画图了,截的书上的图如下:
信号驱动I/O是指进程预先告知内核,向内核注册一个信号处理函数,然后用户进程返回不阻塞,当内核数据就绪时会发送一个信号给进程,用户进程便在信号处理函数中调用I/O读取数据。从上图可以看出,实际上I/O内核拷贝到用户进程的过程还是阻塞的,信号驱动I/O并没有实现真正的异步,因为通知到进程之后,依然由进程来完成I/O操作。这和后面的异步I/O模型很容易混淆,需要理解I/O交互并结合五种I/O模型进行比较阅读。信号驱动I/O模型的特性总结如下表所示
AI/O( 异步I/O)
Linux下的asynchronous IO其实用得很少。流程如下:
用户进程发起aio_read操作后,给内核传递与read相同的描述符、缓冲区指针、缓冲区大小三个参数及文件偏移,告诉内核当整个操作完成时,如何通知我们立刻就可以开始去做其他的事;而另一方面,从内核的角度,当它收到一个aio_read之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个信号,告诉它aio_read操作完成。异步I/O的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们,这种模型与信号驱动I/O模型的区别在于,信号驱动I/O模型是由内核通知我们何时可以启动一个I/O操作,这个I/O操作由用户自定义的信号函数来实现,而异步I/O模型由内核告知我们I/O操作何时完成。异步I/O模型的特性总结如下表所示。
各个模型的阻塞状态对比
各个模型的阻塞状态对比流程图如下
从上图可以看出,阻塞程度:阻塞I/O>非阻塞I/O>多路复用I/O>信号驱动I/O>异步I/O,效率是由低到高的。最后,再看一下下表,从多维度总结了各I/O模型之间的差异,可以加深理解。