IO模型及JAVA中的(B)IO/NIO/AIO

本文详细介绍了Java中的IO模型,包括阻塞IO(BIO)、非阻塞IO(NIO)和异步非阻塞IO(AIO)。解释了同步与异步、阻塞与非阻塞的概念,分析了各种IO模型的工作原理,如多路复用IO(select、poll、epoll)和信号驱动IO。文章还对比了BIO、NIO和AIO在不同场景下的适用性,以及各自的优势和限制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。网络IO的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。对于一次IO访问(以read举例)当一个read操作发生时,它会经历两个阶段:

第一阶段:等待数据准备,数据从磁盘拷贝到内核空间 (Waiting for the data to be ready)。

第二阶段:将数据从内核空间拷贝到进程空间 (Copying the data from the kernel to the process)。

同时,上面两个阶段都是在内核中进行的,由内核进程来实现。

网络IO的模型大致有如下几种:

阻塞IO(bloking IO)

非阻塞IO(non-blocking IO)

多路复用IO(multiplexing IO) select,poll,epoll

信号驱动式IO(signal-driven IO)

异步IO(asynchronous IO)

(一)同步与异步,阻塞与非阻塞

在学习IO之前,先了解一下同步,异步,阻塞,非阻塞的区别。

阻塞和非阻塞强调的是线程在等待调用结果(消息,返回值)时的状态。 阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。非阻塞调用指在在得到结果之前,该调用不会阻塞当前线程,线程可以继续执行。

同步和异步强调的是线程之间的消息通信机制。所谓同步,就是在发出一个"调用"时,在没有得到结果之前,该“调用”就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由“调用者”主动等待这个“调用”的结果。而异步则是相反,"调用"在发出之后,就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在"调用"发出后,"被调用者"通过状态、通知来通知调用者,或通过回调函数处理这个调用


接下来,可以看看在IO中,阻塞和非阻塞,同步和异步的具体实现方式。

阻塞式IO模型(blocking I/O

很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。

非阻塞IO模型(noblocking I/O

当用户进程发出read操作时,如果内核中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,noblocking IO的特点就是-等待数据-阶段不会被阻塞,-拷贝数据-阶段会被阻塞。

多路复用IOI/O multiplexing

 

IO多路复用建立在内核提供的阻塞函数select上,用户先将需要文件描述符(要进行IO操作的文件或socket)添加到select中,内核“监视”select中所有的文件描述符,当任何一个socket中的数据准备好了,select就会返回。用户进程再调用recvfrom操作,内核将数据拷贝到用户进程

所以,I/O multiplexing的特点也是-等待数据-阶段不会被阻塞,-拷贝数据-阶段会被阻塞,同时单个线程可以监控多个IO

信号驱动IOsignal blocking I/O

 

信号驱动IO模型,用户进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并调用recvfrom函数。内核则将数据拷贝到用户进程

异步IOasynchronous I/O

真正的异步IO需要操作系统更强的支持。Linux下的asynchronous IO其实用得不多,从内核2.6版本才开始引入。 

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个signal,告诉它read操作完成了。

看上去,异步IO与非阻塞IO似乎是一样的,但实际上,他们是有所区别的。在--拷贝数据--阶段,非阻塞IO需要自己调用recvfrom函数,并会被阻塞,直到数据拷贝完成。而异步IO在该阶段也不会被阻塞,因为数据拷贝的工作是由操作系统来完成的,当用户线程收到通知时,数据已经被操作系统从内核拷贝到用户指定的缓冲区内,用户线程直接使用即可。

所以,asynchronous I/O的特点也是-等待数据-阶段不会被阻塞,-拷贝数据-阶段也不会被阻塞,数据由操作系统从内核拷贝到用户指定的缓冲区内。

在五种IO模型,阻塞IO,非阻塞IO,多路复用IO,信号驱动IO由于其在IO的第二个阶段-拷贝数据-都会被阻塞,所以都是同步IO

(一)同步IO与异步IO

同步和异步IO关注的是程序在拷贝数据时的状态,即 -拷贝数据-阶段,进程是否被阻塞

两者的区别就在于synchronous IOIO operation的时候会将进程阻塞。需要注意IO operation是指真实的IO操作,就是例子中的recvfrom系统调用。non-blocking IO在执行recvfrom这个系统调用的时候,如果内核的数据没有准备好,这时候不会block进程。但是当内核中数据准备好的时候,recvfrom会将数据从内核拷贝到用户内存中,这个时候进程是被block。而asynchronous IO则不一样,当进程发起IO操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block

(二)阻塞IO和非阻塞IO

阻塞与非阻塞IO关注的是程序在等待调用结果(消息,返回值)时的状态-数据准备-阶段,进程是否被阻塞。

阻塞:是指调用结果返回之前当前线程被挂起,调用线程只有在得到结果之后才会返回。

非阻塞:指在不能立刻得到结果之前,该调用不会阻塞当前线程

(三)同步阻塞IO,同步非阻塞IO,异步阻塞IO,异步非阻塞IO

使用一个例子说明:

小明用水壶烧水。烧水有两个阶段:水还没开(对应-数据准备-阶段),水开了(对应-数据拷贝-阶段)。

1. 小明把水壶放到火上,在旁边等着水开;水开了之后,自己把开水拿到客厅。(同步阻塞IO

2. 小明把水壶放到火上,去客厅看电视时不时去厨房看看水开没有;水开了之后,自己把开水拿到客厅同步非阻塞IO

3. 小明把水壶放到火上,在旁边等着水开;水开了之后,小红帮他拿到客厅。(异步阻塞IO

4. 小明把水壶放到火上,去客厅看电视不再理会烧水这件事;水开之后,小红会自动把水拿到客厅异步非阻塞IO

(四)(B)IO(同步阻塞IO)

BIO 就是传统的 java.io 包,它是基于流模型实现的,交互的方式是同步、阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用时可靠的线性顺序。它的优点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。

服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。 

(五)NIO(同步非阻塞IO)

NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel、Selector、Buffer 等新的抽象,是同步非阻塞的IO。

服务器实现模式为一个线程多个请求,即客户端发送的连接请求都会注册多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。用户进程也需要时不时的询问IO操作是否就绪,这就要求用户进程不停的去询问。

NIO主要有三大核心:Channel(通道),Buffer(缓冲区),Selector(多路复用器)。传统IO是基于字节流字符流进行操作,而NIO是基于ChannelBuffer进行操作数据总是通过通道读到缓冲区,或者通过通道缓冲区写入到文件。Selector用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。

NIO的缓冲区

JAVA IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据, 需要先将它缓存到一个缓冲区。 NIO 的缓冲导向方法不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。

Channel

 Channel,国内大多翻译成“通道”。 Channel 和 IO 中的 Stream(流)是差不多一个等级的。 只不过 Stream 是单向的,譬如: InputStream, OutputStream, 而 Channel 是双向的,既可以用来进行读操作,又可以用来进行写操作。

Buffer

Buffer,故名思意, 缓冲区,实际上是一个容器,是一个连续数组。 Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer。

上面的图描述了从一个客户端向服务端发送数据,然后服务端接收数据的过程。客户端发送数据时,必须先将数据存入 Buffer 中,然后将 Buffer 中的内容写入通道。服务端这边接收数据必须通过 Channel 将数据读入到 Buffer 中,然后再从 Buffer 中取出数据来处理。

Selector

 Selector 类是 NIO 的核心类, Selector 能够检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的响应处理。这样一来,只是用一个单线程就可以管理多个通道,也就是管理多个连接。这样使得只有在连接真正有读写事件发生时,才会调用函数来进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程,并且避免了多线程之间的上下文切换导致的开销。

(六)AIO(异步非阻塞IO)

AIO 是 Java 1.7 之后引入的包,是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,所以人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

BIONIOAIO适用场景分析

 BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。 

 NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。 

  AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。 

(七)IO多路复用

IO多路复用,就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

select、poll、epoll都是I/O多路复用的具体的实现,这些调用都是内核级别的。但select、poll、epoll本质上都是同步I/O,先是block住等待就绪的socket,再是block住将数据从内核拷贝到用户内存。

select

基本原理监视文件描述符,调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),通过遍历fdset,来找到就绪的描述符。

工作流程:

1)用户态创建了网络 IO 连接,一个 socket 连接就是一个 fd 文件描述符,将该 fd 添加到 fd_set 集合中。

2)将 fd_set 集合从用户态 copy 到内核态。

3)遍历这个 fd_set 集合,找出所有已经就绪的 fd,执行对应 fd 的相关操作。

4)将内核态的 fd_set 集合拷贝到用户态。

 

select目前几乎在所有的平台上支持。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024。对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

poll

基本原理poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后使用轮询来查询每个fd对应的设备状态。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是和select有同样的缺点大量的fd的数组被整体复制于用户态和内核地址空间之间。poll还有一个特点是水平触发,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

epoll

基本原理epoll支持水平触发和边缘触发,还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口),用红黑树存储。每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)

效率提升,不是轮询的方式,只有活跃可用的FD才会调用callback函数,即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中

③ 使用内存映射mmap函数:epoll只要拷贝文件描述符到kernel一次,而select/poll要拷贝多次,内存映射,简而言之就是将用户空间的一段内存区域映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。那么对于内核空间<---->用户空间两者之间需要大量数据传输等操作的话效率是非常高的。

在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来socket插入到准备就绪链表里了

 

水平触发和边缘触发

水平触发只关心文件描述符中是否还有没完成处理的数据,如果有,不管怎样epoll_wait,总是会被返回。简单说——水平触发代表了一种“状态”。

边缘触发只关心文件描述符是否有新的事件产生,如果有,则返回;如果返回过一次,不管程序是否处理了,只要没有新的事件产生,epoll_wait不会再认为这个fd被“触发”了。简单说——边沿触发代表了一个“事件”。

边沿触发把如何处理数据的控制权完全交给了开发者,提供了巨大的灵活性。比如,读取一个http的请求,开发者可以决定只读取http中的headers数据就停下来,然后根据业务逻辑判断是否要继续读(比如需要调用另外一个服务来决定是否继续读)。而不是次次被socket尚有数据的状态烦扰;

I/O多路复用模式:Reactor模式和Proactor模式

Reactor模式应用于同步I/O的场景,Proactor模式应用于异步I/O的场景。

Reactor模式和Proactor模式的主要区别就是真正的读取和写入操作是由谁来完成的Reactor模式中需要应用程序自己读取或者写入数据,而Proactor模式中,应用程序不需要进行实际的读写过程,它只需要从缓存区读取或者写入即可,操作系统会读取缓存区或者写入缓存区到真正的IO设备

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值