BIO_AIO_NIO区别
BIO_AIO_NIO区别
简单对比:
- BIO 就是传统的 java.io 包,它是基于流模型实现的,交互的方式是同步、阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用时可靠的线性顺序。它的有点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。
- NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层高性能的数据操作方式。
- AIO 是 Java 1.7 之后引入的包,是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,所以人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
前言
问题的关键其实就是理解同步/异步、阻塞/非阻塞的含义。
最初我看了很多blog以及一些所谓的面试考题讲解视频,以为理解了同步/异步、阻塞/非阻塞的含义,但其实越想越经不起推敲
比如下面的一些理解(我认为是不对的):
同步和异步(针对请求),阻塞和非阻塞(针对客户端)
在网络请求中,客户端会发出一个请求到服务端
客户端发了请求后,就一直等着服务端的返回相应。 客户端:阻塞。 请求:同步客户端发了请求后,就去干别的事情了,时不时来检查服务端是否给出了响应。 客户端:非阻塞。 请求: 同步。换成异步请求后,客户端发出请求后,就坐在椅子上,等着服务端的返回相应。 客户端:阻塞。 请求:异步。客户端发出请求后,就去干别的事情了,等到服务端给出响应后,再来处理业务逻辑。 客户端:阻塞。 请求:异步。
像这样举通俗的例子来说明这几个概念,通常都经不起推敲,原因在于,例子中缺少了一个“操作系统”级别的调度者。
阻塞非阻塞是跟进程/线程严密相关的,而进程/线程又是依赖于操作系统存在的,所以自然不能脱离操作系统来讨论阻塞非阻塞。
同步/异步也是跟任务流相关的,所以举例子必须考虑到并发的任务流,不然,肯定很难举出恰当的例子的。
本文的讨论:
1. Linux下的五种I/O模型
Stevens在文章中一共比较了五种IO Model:
- blocking IO
- nonblocking IO
- IO multiplexing
- signal driven IO
- asynchronous IO
由于signal driven IO在实际中并不常用,所以只提及剩下的四种IO Model。
IO发生时涉及的对象和步骤:
- 对于一个network IO (这里我们以read举例),它会涉及到两个系统对象:
- 一个是调用这个IO的process (or thread)
- 另一个就是系统内核(kernel)
- 当一个read操作发生时,它会经历两个阶段:
1 等待数据准备 (Waiting for the data to be ready)
2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)- 记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。
1.1 阻塞I/O(blocking I/O)
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
第一阶段:准备数据
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。
第二阶段:数据拷贝
当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。以下是java实现的服务器bio代码(需要掌握),注意阻塞方法阻塞的第一个阶段。
1 |
|
应用程序想要读取数据就会调用recvfrom
,而recvfrom
会通知OS来执行,OS就会判断数据报是否准备好(比如判断是否收到了一个完整的UDP报文,如果收到UDP报文不完整,那么就继续等待)。当数据包准备好了之后,OS就会将数据从内核空间拷贝到用户空间(因为我们的用户程序只能获取用户空间的内存,无法直接获取内核空间的内存)。拷贝完成之后socket.read()
就会解除阻塞,并得到read的结果。
1.2 非阻塞(Non-Blocking IO)
linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
第一阶段:准备数据
从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。
第二阶段:数据拷贝
一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,用户进程其实是需要不断的主动询问kernel数据好了没有。一定要注意这个地方,Non-Blocking还是会阻塞的。
以下是java实现的服务器bio代码(需要掌握),注意数据准备阶段是非阻塞方法。
1 |
|
NIO中提供了集中Channel:ServerSocketChannel;SocketChannel;FileChannel;
DatagramChannel
只有FileChannel
无法设置成非阻塞模式,其他Channel都可以设置为非阻塞模式。
当设置为非阻塞后,我们的socket.read()
方法就会立即得到一个返回结果(成功 or 失败),我们可以根据返回结果执行不同的逻辑,比如在失败时,我们可以做一些其他的事情。但事实上这种方式也是低效的,因为我们不得不使用轮询方法区一直问OS:“我的数据好了没啊”。第一个代码是该线程不断的轮询,第二个代码是使用selector实现轮询等待有效请求。
NIO 不会在recvfrom
也就是socket.read()
时候阻塞,但是还是会在将数据从内核空间拷贝到用户空间阻塞。
1.3 I/O复用 IO multiplexing
IO multiplexing这个词可能有点陌生,但是如果我说select,epoll,大概就都能明白了。
有些地方也称这种IO方式为event driven IO。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。
它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:
这里用java的NIO示意select
1 |
|
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
1.4 异步I/O(Asynchronous IO)
linux下的asynchronous IO其实用得很少(所以这里就不贴AIO的java代码了,一般不要求掌握)。先看一下它的流程:
用户进程发起read操作之后,立刻就可以开始去做其它的事。
而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
Asynchronous IO调用中是真正的无阻塞,其他IO model中多少会有点阻塞。
1.5 基于四个模型,对blocking和non-blocking,synchronous IO和asynchronous IO的理解
其实是POSIX的定义是这样子的:
A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
An asynchronous I/O operation does not cause the requesting process to be blocked;
核心区别是:synchronous IO做”IO operation”的时候会将process阻塞
blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。
而asynchronous IO,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。
有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。
经过上面的介绍,会发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
1.6 select,poll,epoll区别
知乎这篇文章把底层讲得很清楚(一共三篇):https://zhuanlan.zhihu.com/p/64746509
他们是NIO中多路复用的三种实用机制(底层是三个C++的API),是由Linux操作系统提供的。
Unix,Linux操作系统的一些基本知识:
用户空间和内核空间:操作系统为了保护系统安全,将内核划分为两部分,一个是用户空间,一个是内核空间。用户空间不能直接访问底层的硬件设备,必须通过内核空间
文件描述符File Descriptor(FD):是一个抽象概念,形式上是一个整数,实际上是一个索引值。指向内核中为每一个进程维护进程所打开的文件的记录表。当程序打开一个文件或者创建一个文件时,内核就会向进程返回一个FD。
select机制:会维护一个FD的集合fd_set。将fd_set从用户空间复制到内核空间,激活socket。(fd_set是一个数组结构,拷贝大小受限制 x64最大2048)
poll机制:和select机制差不多的。将fd_set结构进行了优化,FD集合大小突破了操作系统限制。(pollfd结构来代替fd_set,通过链表实现的)
Epoll机制:Event Poll不在扫描所有的FD,只将用户关心的FD的事件存放到内核的一个事件表当中。可以减少用户空间与内核空间之间需要拷贝的数据。
操作方式 | 底层实现 | 最大连接数 | IO效率 | |
---|---|---|---|---|
select(1984) | 遍历 | 数组 | 受限于内核 | 一般 |
poll(1997) | 遍历 | 链表 | 无上限 | 一般 |
epoll(2002) | 事件回调 | 红黑树 | 无上限 | 高 |
Java NIO当中是使用哪一种机制?
基于内核版本,可以查看DefaultSelectorProvider源码:
windows下,WindowsSelectorProvider。
Linux下,内核2.6版本以上,就是EpollSelectorProvider,否则就是默认的PollSelectorProvider。
2. Java的IO编程模型
BIO 同步阻塞IO。 可靠性差,吞吐量低,适用于连接比较少且比较固定的场景。JDK1.4之前唯一的选择。
编程模型最简单。
1 |
|
但是如果不活跃的连接逐渐增多,线程池里的线程慢慢的也都开始阻塞等待IO,线程池里真正在运行的线程数会越来越少,当线程池处理不过来时,会放置到线程池配置的BlockingQueue中,队列塞满后,慢慢的线程池中线程的数目会逐渐达到线程池配置的maximumPoolSize,如果再处理不过来,执行拒绝策略。也就是说,最终会导致请求无法及时处理。
这样带来的问题是,假设有线程池corePoolSize设置为100,只要有100个不活跃的连接正在阻塞读写IO,就会把前corePoolSize线程都阻塞住,后续的请求就无法及时处理。
NIO 同步非阻塞IO。 可靠性比较好,吞吐量也比较高,适用于连接比较多且连接比较短(轻操作)。例如聊天室。JDK1.4开始支持。(应用最广)
编程模型最复杂。
NIO非阻塞体现:
- accept方法是阻塞的,但是这里因为发生了连接事件,所以这个方法会马上执行完,不会阻塞
- 首先read方法不会阻塞,其次这种事件响应模型,当调用到read方法时肯定时发生了客户端发送数据的事件
(我的一些看法,java的NIO就是Linux中IO复用和NIO两个模型的结合)
1 |
|
AIO 异步非阻塞IO。解决了服务端需要一直守着线程的问题,可靠性是最好的,吞吐量也是非常高的,适用于连接比较多且比较长(重操作)。 例如相册服务器。 JDK7开始支持。
编程模型比较简单,但需要操作系统来支持异步服务。
1 |
|
3. BIO/NIO区别
4. BIO、NIO、AIO适用场景
- BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择。
- NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂。
- AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。
5. Java NIO的3个核心组件
NIO重点:
Channel(通道),Buffer(缓冲区),Selector(选择器)三个类之间的关系
简单解释:
每一个channel对应一个buffer缓冲区。
channel会注册到selector。
selector会根据channel上发生的读写事件,将请求交由某一个空闲线程处理。
selector对应一个或者多个线程。
buffer和channel都是可读可写的。
5.1 缓冲区Buffer
Buffer是一个对象。它包含一些要写入或者读出的数据。在面向流的I/O中,可以将数据写入或者将数据直接读到Stream对象中。
在NIO中,所有的数据都是用缓冲区处理。这也就本文上面谈到的IO是面向流的,NIO是面向缓冲区的。
缓冲区实质是一个数组,通常它是一个字节数组(ByteBuffer),也可以使用其他类的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数据的结构化访问以及维护读写位置(limit)等信息。
最常用的缓冲区是ByteBuffer,一个ByteBuffer提供了一组功能于操作byte数组。除了ByteBuffer,还有其他的一些缓冲区,事实上,每一种Java基本类型(除了Boolean)都对应一种缓冲区,具体如下:
- ByteBuffer:字节缓冲区
- CharBuffer:字符缓冲区
- ShortBuffer:短整型缓冲区
- IntBuffer:整型缓冲区
- LongBuffer:长整型缓冲区
- FloatBuffer:浮点型缓冲区
- DoubleBuffer:双精度浮点型缓冲区
5.2 通道Channel
Channel是一个通道,可以通过它读取和写入数据,他就像自来水管一样,网络数据通过Channel读取和写入。
通道和流不同之处在于通道是双向的,流只是在一个方向移动,而且通道可以用于读,写或者同时用于读写。
因为Channel是全双工的,所以它比流更好地映射底层操作系统的API,特别是在UNIX网络编程中,底层操作系统的通道都是全双工的,同时支持读和写。
Channel有四种实现:
- FileChannel:是从文件中读取数据。
- DatagramChannel:从UDP网络中读取或者写入数据。
- SocketChannel:从TCP网络中读取或者写入数据。
- ServerSocketChannel:允许你监听来自TCP的连接,就像服务器一样。每一个连接都会有一个SocketChannel产生。
5.3 多路复用器Selector
Selector选择器可以监听多个Channel通道感兴趣的事情(read、write、accept(服务端接收)、connect,实现一个线程管理多个Channel,节省线程切换上下文的资源消耗。Selector只能管理非阻塞的通道,FileChannel是阻塞的,无法管理。
关键对象
- Selector:选择器对象,通道注册、通道监听对象和Selector相关。
- SelectorKey:通道监听关键字,通过它来监听通道状态。
监听注册
监听注册在Selector
socketChannel.register(selector, SelectionKey.OP_READ);
监听的事件有
- OP_ACCEPT: 接收就绪,serviceSocketChannel使用的
- OP_READ: 读取就绪,socketChannel使用
- OP_WRITE: 写入就绪,socketChannel使用
- OP_CONNECT: 连接就绪,socketChannel使用
5.4 NIO的一些应用和框架
例如:Dubbo(服务框架),就默认使用Netty作为基础通信组件,用于实现各进程节点之间的内部通信。
Jetty、Mina、Netty、Dubbo、ZooKeeper等都是基于NIO方式实现
参考资料
- UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking,Richard Stevens,6.2节 I/O Models ↩
- https://blog.csdn.net/historyasamirror/article/details/5778378 ↩
- https://zhuanlan.zhihu.com/p/83597838 ↩
- https://zhuanlan.zhihu.com/p/112810033 ↩
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!