IO模型详解:BIO、NIO、IO多路复用、AIO

注:该文章所用图片来自《netty、zk、redis高并发实战》一书。

一、IO读写底层原理

在详细了解IO模型之前,首先要先说明IO读写的原理。
我们的JAVA程序进行IO的读写时,是依赖底层的IO读写的,也就是要与操作系统底层做交互。读写并不是发送read/write指令就直接把数据从物理设备读到内存里。我们程序的IO操作,其实并非物理设备的读写,而是缓存的读写。
我们的程序有进程缓冲区,底层有内核缓冲区,在进行一次read时,我们会经历以下阶段:
1、linux通过网卡读取客户端的请求数据,等待数据准备好,复制到内核缓冲区
2、java系统进行read系统调用,把数据从内核缓冲区复制到进程缓冲区

在进行一次write时,会经历以下阶段:
1、java程序通过write调用,把数据从进程缓冲区复制到内核缓冲区
2、linux内核通过网络IO,把数据写入网卡,网卡通过底层的数据协议,再发给目标客户端。

二、阻塞/非阻塞,同步/异步

1、什么是阻塞/非阻塞

阻塞:内核IO彻底完成后,才返回到用户空间执行用户的操作。在java中,默认创建的socket都是阻塞的。
非阻塞:用户空间的程序不需要等待内核IO操作完成,内核会立刻返回给用户一个状态值(成功还是失败),用户可以根据结果执行用户的操作。

打个比方,其实就是你在烧水的时候,用不用站在水壶旁边等水开。阻塞就是烧水的时候就专心等水开,非阻塞在你烧水的时候,你还能去扫个地。

2、什么是同步/异步

同步:由用户线程向内核主动发起IO请求。
异步:用户空间与内核空间的调用反过来,用户空间的线程变成被调用方,类似回调模式,用户向操作系统内核注册IO事件的回调函数,然后由内核来主动调用。

打个比方,烧水的时候,是水壶发出叫声告诉你他开了,还是你自己隔一段时间去看他开没开。比如异步非阻塞就是你在烧水的时候扫地,然后那边水壶叫了,表示水开了,那么就是烧水这个行为完成了。
同步非阻塞就是你在烧水的时候扫地,然后你自己隔一段时间看一下水开没开。

其实真实的IO操作比上述描述复杂很多,因为涉及到了内核数据是否准备完成,因为在内核数据没准备好的时候,如果用户线程发出一个读请求,那么会直接返回失败,然后用户线程要不停地进行IO系统读调用,轮询是不是准备好了数据。如果准备好了,那么接下来从内核缓冲区复制到用户缓冲区,最后到内核返回结果的操作都是阻塞的操作,只有用户线程读到数据才会解除阻塞状态)总结一下就是在内核等待数据的时候可以立刻返回。

三、四种IO模型详解

1、同步阻塞IO(BIO)

在java程序里,默认的socket连接的IO操作都是BIO,在阻塞模型中,用户从IO调用开始到返回,全都是阻塞的。
IO模型详解:BIO、NIO、IO多路复用、AIO
如上图,从java开始read调用开始,就进入了阻塞,系统内核收到了read调用,就开始准备数据,如果数据没准备好,内核就会开始阻塞式的等待,当完整的数据到达后,将数据从内核缓冲区复制到用户缓冲区,复制完成后,内核返回结果,用户线程收到结果,才会解除堵塞状态。

这种阻塞式的IO,开发和理解都非常简单,在阻塞期间,用户线程会挂起,不会占用CPU资源。
但是,一般来说,一个连接会用一个线程来维护,如果并发量小,可以选用这种模式,但是高并发时,线程的创建、销毁、切换都会占用很大的资源,多个连接对网络资源的消耗也是巨大的。

2、同步非阻塞IO(NIO)

在linux系统,可以设置socket为非阻塞模式,在这种模式下,应用程序进行一次IO调用,如果内核没有数据,会立刻返回一个调用失败的信息(而不是BIO那样等待内核数据复制),如果内核数据有数据,那么数据从内核缓存复制到用户缓存,复制完成,会返回成功,而这个过程是阻塞的。
IO模型详解:BIO、NIO、IO多路复用、AIO
如上图,内核没有准备好数据会立刻返回,所以用户进程要不断地发起IO系统调用,当内核数据到达后,用户线程发起read系统调用,开始阻塞,数据会从内核缓存区复制到用户缓冲区,然后返回结果。

用户需要不断地进行IO系统调用,以轮询数据是不是准备完成,在内核等待数据的过程中不会阻塞了,可以立刻返回。但是不断地轮询也会消耗CPU。一般不会使用,而是使用基于NIO模型改进的IO多路复用模型。

3、IO多路复用模型

前面说到NIO模型中需要不断地轮询等待,消耗CPU资源,这时就出现了IO多路复用模型。

IO多路复用模型,用到了操作系统底层的select/epoll等系统调用,这种系统调用让一个进程可以监视多个文件描述符,一旦某个文件描述符就绪了(也就是内核缓冲区可读或者可写了),就会将这种就绪的状态返回给用户进程。

在这种模式下,我们首先将要监视的socket连接,提前注册到select/epoll,然后开始对就绪状态的轮询,java的选择器类是Selector,阻塞方法是Selector.select查询方法。我们可以通过这个阻塞方法,获取到注册过的socket连接的就绪状态列表。当我们获取到这个列表后,根据其中的socket连接发起read/write系统调用,然后用户线程阻塞,将数据从缓冲区复制到缓冲区,复制完成后,解除阻塞。

其实IO多路复用模型也需要轮询,但是只用一个专门负责select/epoll状态查询调用的线程来轮询。对于每个注册在选择器上的socket连接,用的都是NIO模型。

所以IO多路复用模型比NIO模型先进在,一个选择器查询线程可以处理成千上万个连接,不用维护大量线程了。
但是,内核数据准备完成之后,进行的读写操作还是阻塞的,如果要彻底解决这点,就只能用异步非阻塞模型了。
java的nio用的就是这个模型。

4、异步非阻塞IO(AIO)

用户线程通过系统调用,向内核注册IO操作,内核在IO操作完成后,通知用户程序,这就是AIO。AIO从物理设备复制到内核缓冲区,到内核缓冲区复制到用户缓冲区都是非阻塞的。

这种模型吞吐量比IO复用好很多,但是需要操作系统底层的支持,linux现在底层的AIO模型仍然使用的是epoll,性能上并没有很大提升。所以现在一般还是使用IO多路复用模型。