什么是阻塞和非阻塞?什么是同步和异步?什么是BIO、NIO、AIO?
一、何为 I/O?
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
输入输出I/O流可以看成对字节或者包装后的字节的读取就是拿出来放进去双路切换。
平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和相应)。
传统的 IO 大致可以分为4种类型:
- InputStream、OutputStream 基于字节操作的 IO
- Writer、Reader 基于字符操作的 IO
- File 基于磁盘操作的 IO
- Socket 基于网络操作的 IO
二、用户空间 和 内核空间
为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统级资源有关的操作,比如如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
并且,用户空间的程序不能直接访问内核空间。当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间。
也就是说,应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间。
三、什么是阻塞和非阻塞?什么是同步和异步?
- 同步,一个任务的完成之前不能做其他操作,必须等待(等于在打电话)
- 异步,一个任务的完成之前,可以进行其他操作(等于在聊QQ)
- 阻塞,是相对于CPU来说的, 挂起当前线程,不能做其他操作只能等待
- 非阻塞,,无须挂起当前线程,可以去执行其他操作
归纳
A:阻塞与非阻塞是针对线程来说的
B:同步与异步是针对IO操作来说的
四、组合概念
同/异、阻/非堵塞的组合,有四种类型,如下表:
2.1 同步阻塞IO(BIO、即传统的IO模型)
同步体现在IO完成之前用户线程不能做别的事情。
阻塞体现在用户线程从发送read请求开始一直到内核线程完成IO读写和数据拷贝都是堵住的。
BIO时,应用程序发起 read 调用后,会一直阻塞,直到在内核把数据拷贝到用户空间。
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
2.2 同步非阻塞IO
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。 即用户需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。这种是主动的询问方式,相比于同步非阻塞 IO 被动阻塞等消息的方式要好。
这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
2.3 I/O 多路复用模型 (NIO)
上面的同步非阻塞IO的应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
这个时候,I/O 多路复用模型 就上场了。
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间->用户空间)还是阻塞的。
内核数据准备过程就是从一个 channel(通道)将数据读到一个Buffer
(缓冲区)中 (或者反过来)的过程。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。它是询问内核的数据准备情况后才发起的read,和前面的IO模型比,IO 多路复用模型阻塞时间短很多。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
传统IO基于字节流和字符流进行操作,而NIO基于Channel和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(多路复用器)用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。
IO:
IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
NIO:
一个线程可以从一个 channel(通道)将数据读到一个
Buffer
(缓冲区)中。当Channel
将数据读入Buffer
时,线程可以做其他事情。一旦数据读入Buffer
结束,线程就可以继续处理数据。向Channel
写入数据也是如此。
NIO和传统IO(一下简称IO)之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。
NIO优点:
- 通过Channel注册到Selector上的状态来实现一种客户端与服务端的通信。
- Channel中数据的读取是通过Buffer , 一种非阻塞的读取方式。
- Selector 多路复用器 单线程模型, 线程的资源开销相对比较小。
一个Selector
允许一个线程处理多个Channel
。如果您的应用程序有许多连接(Channel)打开,但每个连接的流量很小,这就很方便。例如聊天服务器。
总之,
同步阻塞模式,所以需要多线程以实现多任务处理。而 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。
2.4 异步非阻塞IO(AIO,Asynchronous IO)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。
异步IO模型中,用户线程直接使用内核提供的异步IO API发起read请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的AsynchronousOperation和CompletionHandler注册到内核,然后操作系统开启独立的内核线程去处理IO操作。当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。最后内核将read的数据和用户线程注册的CompletionHandler分发给内部Proactor,Proactor将IO完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步IO。
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。
小结:
- BIO是阻塞的,NIO是非阻塞的.
- BIO是面向流的,只能单向读写,NIO是面向缓冲的, 可以双向读写
- 使用BIO做Socket连接时,由于单向读写,当没有数据时,会挂起当前线程,阻塞等待,为防止影响其它连接,,需要为每个连接新建线程处理.,然而系统资源是有限的,,不能过多的新建线程,线程过多带来线程上下文的切换,从来带来更大的性能损耗
- 因此需要使用NIO进行BIO多路复用,使用一个线程来监听所有Socket连接,使用本线程或者其他线程处理连接
- AIO是非阻塞以异步方式发起 I/O 操作。当 I/O 操作进行时可以去做其他操作,由操作系统内核空间提醒IO操作已完成
例如:
当我们调用socket.read()、socket.write()这类阻塞函数的时候,这类函数不能立即返回,也无法中断,需要等待socket可读或者可写,才会返回,因此一个线程只能处理一个请求。在这等待的过程中,cpu并不干活,(即阻塞住了),那么cpu的资源就没有很好地利用起来。因此对于这种情况,我们使用多线程来提高cpu资源的利用率:在等待的这段时间,就可以切换到别的线程去处理事件,直到socket可读或可写了,通过中断信号通知cpu,再切换回来继续处理数据。例如线程A正在等待socket可读,而线程B已经就绪了,那么就可以先切换到线程B去处理。虽然上下文切换也会花一些时间,但是远比阻塞在线程A这里空等要好。当然计算机内部实际的情况比这复杂得多。
而NIO的读写函数可以立刻返回,这就给了我们不开线程利用CPU的最好机会:如果一个连接不能读写(socket.read()返回0或者socket.write()返回0),我们可以把这件事记下来。因此只需要一个线程不断地轮询这些事件,一旦有就绪的时间,处理即可。不需要多线程。
因此,
阻塞型IO:
- 需要多线程,即需要很大的线程池。
- 每个请求都要有一个单独的线程去处理
非阻塞型IO:
- 只需要数量非常少的线程。
- 固定的几个工作线程去处理事件。
非阻塞的关键预期好处是能够以小的固定数量的线程和较少的内存进行扩展。
补充一:Redis是单线程为啥还快?
Redis采用了IO多路复用(异步阻塞),可达到在同一个线程内同时处理多个IO请求的目的。同样的效果,在同步阻塞模型中,必须通过多线程的方式才能达到。
可以打个比方:
就好比去银行办业务,只有一个窗口开放,大家都排在这个窗口下等待办业务。轮到某个人时,他的业务需要先要向银行经理阐述业务需求然后从窗口领到表格(2分钟),然后到一边填写表格(10分钟)。
多路复用就是:你去填表格时,银行经理不停下,继续接待下一个客户,等你这边填完了表格继续回去窗口那里完成业务办理。
好处就是银行经理一直在办业务,没有停下来等待,业务处理效率就能得到保证。这里一个窗口就好比是单线程,这里银行经理就是一个线程,每个客户就是一个Socket,每个业务就是一个IO请求,一个线程(客户经理)能够不间断地处理多个Socket(客户)的多个业务(IO)。
同步阻塞BIO 或 多线程 就是:多开几个服务窗口(多个线程)。
Redis即使是单线程的,但是它的操作是异步的,每个读、写操作都是不需要等待的,再加上都是基于内存的操作因此很快。
补充二:BIO 和 NIO 作为 Server 端,当建立了 10 个连接时,分别产生多少个线程?
因为传统的 IO 也就是 BIO 是同步线程堵塞的,所以每个连接都要分配一个专用线程来处理请求,这样 10 个连接就会创建 10 个线程去处理。而 NIO 是一种同步非阻塞的 I/O 模型,它的核心技术是多路复用,可以使用一个链接上的不同通道来处理不同的请求,所以即使有 10 个连接,对于 NIO 来说,开启 1 个线程就够了。
补充三:使用NIO我们能得到什么?
- 事件驱动模型
- 避免多线程
- 单线程处理多任务
- 非阻塞I/O,I/O读写不再阻塞,而是返回0
- 基于block的传输,通常比基于流的传输更高效
- 更高级的IO函数,zero-copy
- IO多路复用大大提高了Java网络应用的可伸缩性和实用性