1、什么是I/O
在计算机操作系统中,所谓的I/O就是输入(Input)和输出(Output),也可以理解为读(Read)和写(Write),针对不同的对象,I/O模式可以划分为磁盘IO模型和网络IO模型。
IO操作会涉及到用户空间和内核空间的转换,先来理解以下规则:
内存空间分为用户空间和内核空间,也称为用户缓冲区和内核缓冲区
用户的应用程序不能直接操作内核空间,需要将数据从内核空间拷贝到用户空间才能使用
无论是read操作,还是write操作,都只能在内核空间里执行
磁盘IO和网络IO请求加载到内存的数据都是先放在内核空间的
再来看看所谓的读(Read)和写(Write)操作:
读操作:操作系统检查内核缓冲区有没有需要的数据,如果内核缓冲区已经有需要的数据了,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,对于磁盘IO,直接从磁盘中读取到内核缓冲区(这个过程可以不需要cpu参与)。而对于网络IO,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议找中读取客户端发送的数据到内核空间,然后把内核空间的数据copy到用户空间,供应用程序使用。
写操作:用户的应用程序将数据从用户空间copy到内核空间的缓冲区中,这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘或通过网络发送出去,由操作系统决定。除非应用程序显示地调用了sync命令,立即把数据写入磁盘,或执行flush()方法,通过网络把数据发送出去。
绝大多数磁盘IO和网络IO的读写操作都是上述过程,除了后面要讲到的零拷贝IO。
2、 网络IO
网络IO的流程如下
2.1、读操作
网络IO的既可以从物理磁盘中读数据,也可以从socket中读数据(从网卡中获取)。当从物理磁盘中读数据的时候,其流程和磁盘IO的读操作一样。当从socket中读数据,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议找中读取客户端发送的数据到内核空间(这个过程也由DMA控制),然后把内核空间的数据copy到用户空间,供应用程序使用。
2.2、写操作
为了简化描述,我们假设网络IO的数据从磁盘中获取,读写操作的流程如下:
当应用程序调用read()方法时,通过DMA方式将数据从磁盘拷贝到内核缓冲区,由cpu控制,将内核缓冲区的数据拷贝到用户空间的缓冲区中,供应用程序使用
当应用程序调用write()方法时,cpu会把用户缓冲区中的数据copy到内核缓冲区的Socket Buffer中
最后通过DMA方式将内核空间中的Socket Buffer拷贝到Socket协议栈(即网卡设备)中传输。
网络IO的写操作也有四次缓冲区的copy,第一次是从磁盘缓冲区到内核缓冲区(由cpu控制),第二次是内核缓冲区到用户缓冲区(DMA控制),第三次是用户缓冲区到内核缓冲区的Socket Buffer(由cpu控制),第四次是从内核缓冲区的Socket Buffer到网卡设备(由DMA控制)。四次缓冲区的copy工作两次由cpu控制,两次由DMA控制。
2.3、 网络IO的延时
网络IO主要延时是由:服务器响应延时+带宽限制+网络延时+跳转路由延时+本地接收延时 决定。一般为几十到几千毫秒,受环境影响较大。所以,一般来说,网络IO延时要大于磁盘IO延时。
2.4. IO中断与DMA
以前传统的IO读写是通过中断由cpu控制的,为了减少CPU对I/O的干预,引入了直接存储器访问方式(DMA)方式。在DMA方式下,数据的传送是在DMA的控制下完成的,不需要cpu干预,所以CPU和I/O设备可以并行工作,提高了效率。现在来看看它们各自的原理:
IO中断原理
用户进程通过read等系统调用接口向操作系统(即CPU)发出IO请求,请求读取数据到自己的用户内存缓冲区中,然后该进程进入阻塞状态。
操作系统收到用户进程的请求后,进一步将IO请求发送给DMA,然后CPU就可以去干别的事了。
DMA将IO请求转发给磁盘。
磁盘驱动器收到内核的IO请求后,把数据读取到自己的缓冲区中,当磁盘的缓冲区被读满后,向DMA发起中断信号告知自己缓冲区已满。
DMA收到磁盘驱动器的信号,将磁盘缓存中的数据copy到内核缓冲区中,此时不占用CPU(IO中断这里是占用CPU的)。
如果内核缓冲区的数据少于用户申请读的数据,则重复步骤3、4、5,直到内核缓冲区的数据符合用户的要求为止。
内核缓冲区的数据已经符合用户的要求,DMA停止向磁盘IO请求。
DMA发送中断信号给CPU。
CPU收到DMA的信号,知道数据已经准备好,于是将数据从内核空间copy到用户空间,系统调用返回。
用户进程读取到数据后继续执行原来的任务。
跟IO中断模式相比,DMA模式下,DMA就是CPU的一个代理,它负责了一部分的拷贝工作,从而减轻了CPU的负担。
需要注意的是,DMA承担的工作是从磁盘的缓冲区到内核缓冲区或网卡设备到内核的soket buffer的拷贝工作,以及内核缓冲区到磁盘缓冲区或内核的soket buffer到网卡设备的拷贝工作,而内核缓冲区到用户缓冲区之间的拷贝工作仍然由CPU负责。
2.5、零拷贝IO
在上述IO中,读写操作要经过四次缓冲区的拷贝,并经历了四次内核态和用户态的切换。 零拷贝(zero copy)IO技术减少不必要的内核缓冲区跟用户缓冲区之间的拷贝,从而减少CPU的开销和状态切换带来的开销,达到性能的提升。
在zero copy下,如果从磁盘中读取文件然后通过网络发送出去,只需要拷贝三次,只发生两次内核态和用户态的切换。
下图是不使用zero copy的网络IO传输过程:
零拷贝的传输过程:硬盘 >> kernel buffer (快速拷贝到kernel socket buffer) >>Socket协议栈(网卡设备中)
当应用程序调用read()方法时,通过DMA方式将数据从磁盘拷贝到内核缓冲区
由cpu控制,将内核缓冲区的数据直接拷贝到另外一个与 socket相关的内核缓冲区,即kernel socket buffer,然后由DMA 把数据从kernel socket buffer直接拷贝给Socket协议栈(网卡设备中)。
这里,只经历了三次缓冲区的拷贝,第一次是从磁盘缓冲区到内核缓冲区,第二次是从内核缓冲区到kernel socket buffer,第三次是从kernel socket buffe到Socket协议栈(网卡设备中)。只发生两次内核态和用户态的切换,第一次是当应用程序调用read()方法时,用户态切换到内核到执行read系统调用,第二次是将数据从网络中发送出去后系统调用返回,从内核态切换到用户态。
零拷贝(zero copy)的应用:
Linux下提供了zero copy的接口:sendfile和splice,用户可通过这两个接口实现零拷贝传输
Nginx可以通过sendfile配置开启零拷贝
在linux系统中,Java NIO中FileChannel.transferTo的实现依赖于 sendfile()调用。
Apache使用了sendfile64()来传送文件,sendfile64()是sendfile()的扩展实现
kafka也用到了零拷贝的功能,具体我没有深究
注意:零拷贝要求输入的fd必须是文件句柄,不能是socket,输出的fd必须是socket,也就是说,数据的来源必须是从本地的磁盘,而不能是从网络中,如果数据来源于socket,就不能使用零拷贝功能了。我们看一下sendfile接口就知道了:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
out_fd:待写入文件描述符
in_fd: 待读出文件描述符
offset:从读入文件流的哪个位置开始读,如果为空,则默认从起始位置开始
count:指定在文件描述符in_fd 和out_fd之间传输的字节数
返回值:成功时,返回出传输的字节数,失败返回-1
in_fd必须指向真实的文件,不能是socket和管道;而out_fd则必须是一个socket。由此可见,sendfile几乎是专门为在网络上传输文件而设计的。
在Linxu系统中,一切皆文件,因此socket也是一个文件,也有文件句柄(或文件描述符)。
3、 BIO
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(9999)); // 绑定端口号9999
while (true) {
Socket client = serverSocket.accept(); // 阻塞等待客户端连接
new Thread(new Accept(client)).start(); // 为每个客户端连接创建一个新线程处理请求
}
}
}
class Accept implements Runnable {
private Socket client = null;
public Accept(Socket client) {
this.client = client;
}
@Override
public void run() {
try (InputStream inputStream = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
String dataLine;
while ((dataLine = reader.readLine()) != null) {
System.out.println(dataLine); // 处理接收到的数据
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
client.close(); // 关闭客户端连接
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
现在,我们就来讲解BIO、NIO、IO多路复用、AIO,在这之前,我必须强调,这些IO大多用于网络IO,并且这里主要介绍用户程序从网络中获取数据那一部分。一方面是为了方便描述,另一方式,更能体现出这些IO的区别。
网络IO从Socket获取数据的步骤:
1)用户进程执行系统调用转入内核态
2)操作系统等待远处客户端发送数据(前提是客户端和服务器通过TCP三次握手成功),客户端发送数据后,操作系统通过从网卡设备获取数据,并把数据从Socket协议栈拷贝到内核缓冲区
3)把内核缓冲区的数据拷贝到用户缓冲区
4)用户进程获取到数据,继续执行
BIO、NIO、AIO的主要区别在于:
步骤1里用户进程执行系统调用后的状态如何,是阻塞(或挂起),还是非阻塞。
步骤3里把内核缓冲区的数据拷贝到用户缓冲区,在拷贝过程中,用户进程的状态又如何,是阻塞,还是非阻塞。
如果用户进程在步骤1执行后的状态是阻塞的,且步骤3过程中,进程也是阻塞的,那么是BIO(同步阻塞IO)。
如果用户进程在步骤1执行后的状态是非阻塞的,且步骤3过程中,进程是阻塞的,那么是NIO(同步非阻塞IO)。
如果用户进程在步骤1执行后的状态是非阻塞的,且步骤3过程中,进程也是非阻塞的,也就是说真正读(或写)时,进程的状态是非阻塞的,那么是AIO(异步IO)。
至于多路复用IO和BIO、NIO、AIO的区别,后面会细细讲解。那么,我们就开始吧!
BIO (Blocking I/O),称之为同步阻塞I/O,其IO模型传输如下图所示:
上图红色表示进程处理阻塞状态,绿色表示进程处于非阻塞状态
我相信BIO模型的传输过程上图已经描述很清楚了,可以看到,BIO模型的用户进程在执行系统调用后,一直处于阻塞状态,等待内核数据到位后,进程继续阻塞,直到内核数据拷贝到用户空间。
该模式下,一个线程只能处理一个Socket IO连接,高并发时,服务端会启用大量的线程来处理多个Socket,由于是阻塞IO,会导致大量线程处于阻塞状态,导致cpu资源浪费,且大量线程会导致大量的上下文切换,造成过多的开销。
当前绝大操作系统都支持多线程,当操作系统引入多线程之后,进程的执行实际就是进程中的多个线程在执行,同一时刻,cpu只能执行一个线程,多个线程通过轮询的方式交替执行。
这时你可能会有疑问,用户进程都被阻塞(或挂起)了,在内核态还怎么操作呢?事实上,read和write都是内核级的操作,只要用户进程调用相应的系统调用接口后,内核进程(或线程)在真正执行读和写操作硬件时,与用户进程就没什么关系了。
4. NIO
NIO (Non-blocking IO),称之为非阻塞IO,其传输过程如下:
在NIO模式下,当用户进程执行系统调用后,如果当前数据还没有准备好,则会立即返回(NIO的非阻塞就提现在这里),然后再次进行系统调用,不断测试数据是否准备好。如果数据准备好了,当前进程会进入阻塞转态,直到数据从内核空间拷贝到用户空间,进程才会被唤醒,就可以处理数据了。
NIO模式下,一个线程就可以处理多个Socket连接,没必要开启多线程进行处理(如果多个NIO,会有多个线程一起执行多次系统调用,结果会很可怕)。但是,当有1000个Socket连接时,用户进程会以轮询的方式执行1000次系统调用判断数据有没有准备好,即会发生1000次用户态到内核态的切换,成本几何上升。即使当前只有一个Socket连接,也会重复进行系统调用,因为此时的用户进程不仅要接收新的Socket连接并把它拷贝到内核,还要判断已有的Socket连接是否准备好数据,这都会有系统调用,极大的浪费cpu资源。
5、 IO多路复用
IO多路复用的传输过程如下:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NIOServer {
public static void main(String[] args) throws IOException {
// 打开Selector
Selector selector = Selector.open();
// 打开ServerSocketChannel
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.configureBlocking(false);
serverSocket.socket().bind(new InetSocketAddress(8000));
// 注册ServerSocketChannel到Selector
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
// 轮询就绪的通道
while (true) {
// 非阻塞地选择就绪的通道
selector.select();
// 获取就绪的SelectionKey集合
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> it = selectedKeys.iterator();
// 迭代就绪的通道
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
// 处理就绪的通道
if (key.isAcceptable()) {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = ssc.accept();
socketChannel.configureBlocking(false);
// 注册新接入的通道到Selector
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
int count;
StringBuilder buffer = new StringBuilder();
// 读取数据
while ((count = socketChannel.read(buffer)) > 0) {
// 处理读取到的数据
}
}
// 其他事件类型(如OP_WRITE等)可以在此处进行处理
}
}
}
}
由于NIO会多次执行系统调用进行测试,大大浪费系统的资源,而多路复用IO把轮询多个Socket文件句柄的事情放在内核空间里执行,即让内核负责轮询所有socket(这样就不会有用户态和系统态的切换),当某个或几个socket有数据到达了,返回所有就绪的Socket文件句柄给用户进程,然后用户进程执行read系统调用接口,并进入阻塞状态。内核进程(或线程)把数据从内核空间拷贝到用户空间,用户进程读取到数据就可以进行处理了。
多路复用IO在执行系统调用后,进程就处于阻塞状态,所以多路复用IO本质上也是同步阻塞IO,只不过它是在内核态轮询所有socket,大大提高了IO的处理速度,也减少了系统状态切换的开销。此外,它与同步阻塞的BIO不同,多路复用IO可以使用一个线程同时处理多个Socket的IO请求,这是BIO做不到的。而在BIO中,必须通过多线程的方式才能达到这个目的。
另外,大家可以思考一下,为什么用户进程从网络中获取数据的第一步就要执行系统调用,我举一个例子来说明。
假如一个服务端上的用户进程要读取客户端发来的数据,此时用户进程在用户态,当进程执行了accept()方法获取客户端的链接,此时就得到了客户端Socket的文件句柄(或文件描述符),但是该用户进程并不知道该Socket的文件句柄是否就绪(即是否可读),这就要执行系统调用进入内核态,并把当前网络连接的Socket文件句柄(或文件描述符)复制到内核态。为什么要进入内核态呢?因为数据是从Socket协议栈(或网卡设备)发过来的,要操作硬件设备才能读取数据,所以必须在内核态下判断客户端的Socket是否发来消息。进入内核态以后,内核进程会判断该Socket是否可读(即是否准备好数据),如果准备好了数据,就把数据从Socket协议栈(或网卡设备)拷贝到内核缓冲区,再把内核缓冲区的数据拷贝到用户缓冲区。所以只要有一个客户端的Socket连接到来,就会进入一次系统调用判断Socket的文件句柄是否就绪。这里可能不好理解,但对下面多路复用模式的理解很有用处。
多路复用模式包含三种,即select、poll和epoll,这几种模式主要区别在于获取可读Socket文件句柄的方式。
6、 select
select方法本质其实就是维护了一个文件描述符(fd)数组,以此为基础,实现IO多路复用的功能。这个fd数组有长度限制,在32位系统中,最大值为1024个,而在64位系统中,最
大值为2048个,这个配置可以调用。
select方法被调用,首先需要将fd_set从用户空间拷贝到内核空间,然后内核用poll机制(此poll机制非IO多路复用的那个poll方法)直到有一个fd活跃,或者超时了,方法返回。
fd_set在用户空间和内核空间的频繁复制,效率低。单个进程可监控的fd数量有限制,无论是1024还是2048,对于很多情景来说都是不够用的。基于轮询来实现,效率低。
7 poll
poll本质上和select没有区别,依然需要进行数据结构的复制,依然是基于轮询来实现,但区别就是,select使用的是fd数组,而poll则是维护了一个链表,所以从理论上,poll方法中,单个进程能监听的fd不再有数量限制。但是轮询,复制等select存在的问题,poll依然存在。
8 epoll
epoll就是对select和poll的改进了。它的核心思想是基于事件驱动来实现的,实现起来也并不难,就是给每个fd注册一个回调函数,当fd对应的设备发生IO事件时,就会调用这个回调函数,将该fd放到一个链表中,然后由客户端从该链表中取出一个个fd,以此达到O(1)的时间复度。
poll操作实际上对应着有三个函数:epoll_create,epoll_ctr,epoll_wait
epoll_create
相当于在内核中创建一个存放fd的数据结构。在select和poll方法中,内核都没有为fd准备存放其的数据结构,只是简单粗暴地把数组或者链表复制进来;而epoll则不一样,epoll_create会在内核建立一颗专门用来存放fd结点的红黑树,后续如果有新增的fd结点,都会注册到这个epoll红黑树上。
epoll_ctr
另一点不一样的是,select和poll会一次性将监听的所有fd都复制到内核中,而epoll不一样,当需要添加一个新的fd时,会调用epoll_ctr,给这个fd注册一个回调函数,然后将该fd结点注册到内核中的红黑树中。当该fd对应的设备活跃时,会调用该fd上的回调函数,将该结点存放在一个就绪链表中。这也解决了在内核空间和用户空间之间进行来回复制的问题。
epoll_wait
epoll_wait的做法也很简单,其实直接就是从就绪链表中取结点,这也解决了轮询的问题,时间复杂度变成O(1)所以综合来说,epoll的优点有:
没有最大并发连接的限制,远远比1024或者2048要大。(江湖传言1G的内存上能监听10W个端口)。
效率变高。epoll是基于事件驱动实现的,不会随着fd数量上升而效率下降。
减少内存拷贝的次数。
9. AIO
AIO ( Asynchronous I/O):异步非阻塞I/O模型。传输过程如下:
可以看到,异步非阻塞I/O在判断数据有没有准备好(即Socket是否就绪)和真正读数据两个阶段都是非阻塞的。AIO在第一次执行系统调用后,会注册一个回调函数,内核在检测到某Socket文件句柄就绪,调用该回调函数执行真正的读操作,将数据从内核空间拷贝到用户空间,然后返回给用户使用。在整个过程,用户进程都是非阻塞状态,可以做其它的事情。没有Linux系统采用AIO模型,只有windows的IOCP是此模型。
10、 总结
IO可以分为两个阶段,第一阶段,判断有没有事件发生(或判断数据有没有准备好,或判断Socket是否就绪),第二阶段,在数据准备好以后,执行真正的读(或写)操作,将数据从内核空间拷贝到用户空间。
这几个阶段:
同步阻塞IO(BIO):两个阶段的用户进程都阻塞。
同步非阻塞IO(NIO):第一阶段没有阻塞,但是用户进程(或线程)必须不断的轮询,判断有没有Socket就绪,这时cpu疯狂被占用。第二阶段,数据拷贝的过程是阻塞的。所以,所有的同步过程,在第二阶段都是阻塞的,尽管这是非阻塞的调用。
多路复用:NIO的第一阶段没有阻塞,但是由用户线程不断轮询多个Socket有没有就绪。而多路复用把这件事情交给一个内核线程去处理,速度非常快。select和poll机制下,第一阶段是也是阻塞的,而epoll机制,用户线程除了要执行epoll_create,还要执行epoll_ctl和epoll_wait,所以是非阻塞的。在第二阶段,所有的多路复用IO都是阻塞的。所以,多路复用IO也是同步IO。
异步IO(AIO):两个阶段都是非阻塞的。
另外,不得不提的是,上述的阻塞和非阻塞指的是IO模型,用户进程获取数据后执行业务逻辑的时候,也分异步和同步。比如,进程执行一段很复杂的业务逻辑,需要很长的时间才能返回,也可以注册一个回调函数,等待此段代码执行完毕后,就通知用户进程。例如,nginx在Linux2.6以后的内核中用的IO模型是epoll,即同步IO,而Nginx的worker进程的处理请求的时候是异步的。
业务逻辑的同步和异步概念如下:
同步:所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。
异步:异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起,函数只有在得到结果之后才会返回。有人也许会把阻塞调用和同步调用等同起来,实际上它们是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是当前函数没有返回而已。
非阻塞:非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。