# BIO、NIO、AIO

# 1. 什么是 IO

在 UNIX 操作系统中有这样一种说法:一切皆文件。

而文件是什么呢?在计算机里面,文件实际上就是二进制流,不管什么文件(socket 等等),在输入输出的时候都通过流的方式来传输。

而所谓的 IO,也就是文件的输入(Input)和输出 (Output),在计算机科学 (opens new window)计算机之间或人与计算机之间的信息交换都可以看做是 IO 操作。比如两台计算机通过网卡进行交互,比如向硬盘写入数据或读取硬盘数据,比如人敲击鼠标键盘编写文档就属于输入操作,文档完成保存在磁盘上就属于输出操作。

image-20220124141755398

上图中的IO描述,即为著名的计算机冯诺依曼体系,它大致描述了外部设备与计算机的IO交互过程。

那么我们的应用程序是如何进行IO交互的呢?我们平时编写的代码不会独立的存在,它总是被部署在 Linux 服务器或者各种容器中,应用程序在服务器或者容器中启动后再对外提供服务。因此网络请求数据首先需要和计算机进行交互,才会被交由到对应的程序去进行后续的业务处理。

在 Linux 的世界中,文件是用来描述 Linux 世界的,目录文件、套接字等都是文件。那文件又是什么鬼呢?文件实际就是二进制流,二进制流就是人类世界与计算机世界进行交互的数据媒介。应用从流中读取数据即为 read 操作,当把流中的数据进行写入的时候就是 write 操作。

但是 Linux 系统又是如何区分不同类型的文件呢?实际是通过文件描述符(File Descriptor,FD)来进行区分,文件描述符其实就是个整数,这个整数实际是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。所以对这个整数的操作、就是对这个文件(流)的操作。

文件描述符

内核 (opens new window)(kernel)利用文件描述符(file descriptor)来访问文件。文件描述符是非负整数 (opens new window)。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。---百度百科

就拿网络连接来说,我们创建一个网络 socket,通过系统调用(socket 调用)会返回一个文件描述符(某个整数),那么后续对 socket 的操作就会转化为对这个描述符的操作,主要涉及的操作包括 accept 调用、read 调用以及 write 调用。这里所说的各种调用就是程序通过Linux内核与计算机进行交互。

那么什么是计算机内核?简单说说。

Unix/Linux的体系架构

image-20220124151821499

从图上我们可以看出来通过系统调用将Linux整个体系分为用户态和内核态。

记得之前在分析 synchronized 的锁的膨胀过程中说到过,当它升级为重量级锁的时候,操作系统实现线程之间的切换这就需要从用户态转换到内核态。

内核态

cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。

用户态

只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。

为什么要有用户态和内核态?

由于需要限制不同的程序之间的访问能力,防止他们获取别的程序的内存数据,或者获取外围设备的数据,并发送到网络,CPU划分出两个权限等级 -- 用户态和内核态。

用户态与内核态的切换

所有用户程序都是运行在用户态的,但是有时候程序确实需要做一些内核态的事情,例如从硬盘读取数据,或者从键盘获取输入等,而唯一可以做这些事情的就是操作系统,所以此时程序就需要先操作系统请求以程序的名义来执行这些操作。

这时需要一个这样的机制:用户态程序切换到内核态,但是不能控制在内核态中执行的指令这种机制叫系统调用。(系统调用开销很大,如上面说的 accept、read 调用等等)

# 2. Linux 下的五种 IO 模型

当系统进行 IO操作时,就涉及到用户线程(用户态)和操作系统内核(内核态)两个对象,当一个read操作发生时,分为两个步骤:

  1. 等待数据准备;
  2. 将数据从内核拷贝到进程中。

阻塞和非阻塞

阻塞与非阻塞主要发生在数据准备阶段,是被调用者(服务端)的阻塞与非阻塞,关注的是等待结果返回调用方的状态

阻塞:是指结果返回之前,当前线程被挂起,不做任何事。

非阻塞:是指结果在返回之前,线程可以做一些其他事,不会被挂起。

所谓阻塞就是当用户线程进行数据请求时,如果数据还没准备好,系统并不会立即返回,而是等数据准备好之后再返回。而对于非阻塞而言,用户线程在请求数据时,不管数据有没有准备好,都会直接返回。

同步和异步

同步与异步主要发生在数据加载阶段,是调用者(客户端)的同步与异步,关注的是调用方是否主动获取结果

同步:同步的意思就是调用方需要主动等待结果的返回。

异步:异步的意思就是不需要主动等待结果的返回,而是通过其他手段比如,状态通知,回调函数等。

对于同步而言,我要把数据加载到我自己的进程中,不管系统是否立即返回有无数据的回复,都得一直等或者隔一段时间去轮询,直到数据准备好并加载到我的进程中。而异步呢,它只要和系统说我要这份数据,就可以直接去做别的事情了,等到系统将数据准备好并且加载到相应进程中,就会给进程发送一个信号提示数据已准备好并加载到了进程,这时候进程就可以直接处理数据了。

正是因为上面这两个阶段,Linux 系统产生了五种 IO 模型。

image-20220124164100046

# 2.1 阻塞 IO

当用户应用进程发起系统调用之后,在内核数据没有准备好的情况下,调用一直处于阻塞状态,直到内核准备好数据后,将数据从内核态拷贝到用户态,用户应用进程获取到数据后,本次调用才算完成。

好比你去商店买衣服,你去了之后发现衣服卖完了,那你就在店里面一直等,期间不做任何事(包括看手机),等着商家进货,直到有货为止,这个效率很低。

image-20220124191147411

优点

阻塞式IO很容易上手,一般程序按照read-process的顺序进行处理就好。通常来说我们编写的一个TCP的C/S程序就是阻塞式IO模型的。并且该模型定位错误,在阻塞时整个进程将被挂起,基本不会占用CPU资源。

缺点:

该模型的缺点也十分明显。作为服务器,需要处理同时多个的套接字,使用该模型对具有多个的客户端并发的场景时就显得力不从心。但是我们可以使用多线程技术来弥补这个缺陷。

但是多线程在具有大量连接时,多线程技术带来的资源消耗也不容小看,比如:

如果我们现在有 1000 个连接时,就需要开启 1000 个线程来处理这些连接,于是就会出现下面的情况

  • 线程有内存开销,假设每个线程需要 512K 的存放栈,那么 1000 个连接就需要约 512M 的内存。当并发量高的时候,这样的内存开销是无法接受的。
  • 线程切换有 CPU 开销,这个 CPU 开销体现在上下文切换上,如果线程数越多,那么大多数 CPU 时间都用于上下文切换,这样每个线程的时间槽会非常短,CPU 真正处理数据的时间就会少了非常多。

# 2.2 非阻塞型 IO

非阻塞IO式基于轮询机制的IO模型,应用进程不断轮询检查内核数据是否准备好,如果没有则返回EWOULDBLOCK,进程继续发起recvfrom调用,此时应用可以去处理其他业务。当内核数据准备好后,将内核数据拷贝至用户空间。

你去了商店之后,发现衣服卖完了,这个时候不需要傻傻的等着,你可以去其他地方比如奶茶店,买杯水,但是你还是需要时不时的去商店问老板新衣服到了吗。

image-20220124191230880

优点

这种IO方式也有明显的优势,即不会阻塞在内核的等待数据过程,每次发起的IO请求可以立即返回,不用阻塞等待。在数据量收发不均,等待时间随机性极强的情况下比较常用。

缺点

轮询这一个特征就已近暴露了这个IO模型的缺点。轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。同时,该模型也不便于使用,需要编写复杂的代码。

# 2.3 多路复用 IO

在出现大量的链接时,使用多线程+阻塞IO的编程模型会占用大量的内存。那么IO复用技术在内存占用方面,就有着很好的控制。

当前的高性能反向代理服务器Nginx使用的就是IO复用模型,它以高性能和低资源消耗著称,在大规模并发上也有着很好的表现。

Linux 主要提供了selectpoll 以及epoll等多路复用IO的实现方式,为什么会有三个实现呢?实际上他们的出现都是有时间顺序的,后者的出现都是为了解决前者在使用中出现的问题。

在实际场景中,后端服务器接收大量的 socket 连接,IO多路复用是实际是使用了内核提供的实现函数,在实现函数中有一个参数是文件描述符集合,对这些文件描述符(FD)进行循环监听,当某个文件描述符(FD)就绪时,就对这个文件描述符进行处理。

下面我们分别看下 selectpoll以及epoll这三个实现函数的实现原理:

select

select是操作系统的提供的内核系统调用函数,通过它可以将一组FD传给操作系统,操作系统对这组FD进行遍历,当存在FD处于数据就绪状态后,将其全部返回给调用方,这样应用程序就可以对已经就绪的IO流进行处理了。

当用户进程调用了 select,那么整个进程会被阻塞,而同时,内核会监视所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候,用户进程再调用 read 操作,将数据从内核拷贝到用户进程。

image-20220124191516740

select在使用过程中存在一些问题:

  1. select最多只能监听1024个连接,支持的连接数较少;
  2. select并不会只返回就绪的FD,而是需要用户进程自己一个一个进行遍历找到就绪的FD
  3. 用户进程在调用select时,都需要将FD集合从用户态拷贝到内核态,当FD较多时资源开销相对较大。

poll

poll机制实际与select机制区别不大,只是poll机制去除掉了监听连接数 1024 的限制。

epoll

epoll是在 2.6 内核中提出的,是之前的 selectpoll的增强版本。相对于selectpoll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。

epoll解决了select以及poll机制的大部分问题,主要体现在以下几个方面:

  1. FD发现的变化:内核不再通过轮询遍历的方式找到就绪的FD,而是通过异步IO事件唤醒的方式,当socket有事件发生时,通过回调函数将就绪的FD加入到就绪事件链表中,从而避免了轮询扫描FD集合;
  2. FD返回的变化:内核将已经就绪的FD返回给用户,用户应用程序不需要自己再遍历找到就绪的FD
  3. FD拷贝的变化:epoll和内核共享同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的内存拷贝开销。

image-20220124175007126

优点

IO复用技术的优势在于,只需要使用一个线程就可以管理多个connection,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,所以它也是很大程度上减少了资源占用。

另外IO复用技术还可以同时监听不同协议的 socket。

缺点 在只处理连接数较小的场合,使用select的服务器不一定比多线程+阻塞IO模型效率高,可能延迟更大,因为单个连接处理需要 2 次系统调用(selectrecvfrom),占用时间会有增加,而blocking IO只调用了一个system call (recvfrom)

所以,如果处理的连接数不是很高的话,使用select/epoll的 web server 不一定比使用多线程+阻塞IO模型的 web server 性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

# 2.4 信号驱动 IO

系统存在一个信号捕捉函数,该信号捕捉函数与socket存在关联关系,在用户进程发起sigaction调用之后,用户进程可以去处理其他的业务流程。当内核将数据准备好之后,用户进程会接收到一个SIGIO信号,然后用户进程中断当前的任务发起recvfrom调用从内核读取数据到用户空间再进行数据处理。

image-20220124191930806

从上图可以看到,我们首先开启套接字的信号驱动式IO功能,并通过sigaction系统调用来安装一个信号处理函数,我们进程不会被阻塞。

当数据报准备好读取时,内核就为该进程产生一个SIGIO信号,此时我们可以在信号处理函数中调用recvfrom读取数据报,并通知数据已经准备好,正在等待处理。

特点是当数据已经在内核准备好时,触发信号,信号触发系统调用recvfrom接收数据到用户态。

优点

很明显,我们的线程并没有在等待数据时被阻塞,可以提高资源的利用率。

缺点

信号IO在大量IO操作时可能会因为信号队列溢出导致没法通知,这个是一个非常严重的问题。

# 2.5 异步 IO

所谓异步IO模型,就是用户进程发起系统调用之后,不管内核对应的请求数据是否准备好,都不会阻塞当前进程,立即返回后进程可以继续处理其他的业务。当内核准备好数据之后,系统会从内核复制数据到用户空间,然后通过信号通知用户进程进行数据读取处理。

image-20220124193538089

优点

全程没有阻塞,真正做到了异步。

缺点

理想很美好,现实很骨感。现在的异步IO底层设施还不是很成熟(glibc 的 aio 有 bug , kernel 的 aio 只能以 O_DIRECT 方式做直接 IO),而epoll已经非常成熟了。

# 3. Java 中的 IO 模型

上面描述了 Linux 系统下的 5 中IO模型,而在 Java 中,也存在对应的 IO模型,分别是BIONIO以及AIO三种IO模型。如:

  1. java.io包下基于流模型实现,提供 File 抽象、输入输出流等IO的功能。交互方式是同步、阻塞的方式,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞。java.io包的好处是代码比较简单、直观,缺点则是IO效率和扩展性存在局限性,容易成为应用性能的瓶颈。
  2. java.net包下提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也可以被归类到同步阻塞IO类库,因为网络通信同样是IO行为(网络 IO 和文件 IO 本质其实是一样的)。
  3. 在Java 1.4 中引入了NIO框架(java.nio),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用IO程序,同时提供更接近操作系统底层的高性能数据操作方式。
  4. 在 Java7 中,NIO 有了进一步的改进,也就是 NIO2,引入了异步非阻塞 IO 方式,也被称为 AIO(Asynchronous IO),异步 IO 操作基于事件和回调机制。

# 3.1 BIO

Java BIO(Blocking IO) 就是传统的 Java IO 编程,其相关的类和接口在 java.io 包下。当用户进程向服务端发起请求后,一定等到服务端处理完成有数据返回给用户,用户进程才完成一次IO操作,否则就会阻塞。

服务器实现模式为一个连接一个线程,即客户端有连接请求时,服务器就会需要启动一个线程来进行处理。如下:

BIO

这就相当于在餐厅吃饭,一个服务员就接待一个客户,没事的时候就一直守着这个客户,啥事也不干。客户少的时候可以,一旦多了肯定是不行的。

因此,在网络连接较少的情况下,可以使用 BIO,一旦有上万甚至十万百万的连接,就会存在以下几个问题:

  1. 由于连接需要创建线程,因此线程的创建和销毁消耗会特别消耗系统资源,给服务器造成压力,可以通过线程池机制改善;
  2. 连接建立后,如果当前线程暂时没有数据可读,则当前线程会一直阻塞在 Read 操作上,造成线程资源浪费。
  3. 线程是稀缺资源,频繁的线程上下文切换成本依然很高。

基于BIO模型在处理大量连接时存在上述的问题,因此我们需要一种更加高效的线程模型来应对几十万甚至上百万的客户端连接。

# 3.2 NIO

由于在BIO模型下,Java中在进行IO操作时候是没办法知道什么时候可以读数据或者什么时候可以写数据,由于socket的读写操作不能进行中断,因此当有新的连接到来时,只能不断创建新的线程来处理,从而导致存在性能问题。

那么如何解决这个问题呢?我们都知道问题的根源就是BIO模型中我们不知道数据的读取与写入的时机,才导致的阻塞等待,那么如果我们能够知道数据读写的时机,是不是就不用傻傻的等着响应,也不用再创建新的线程来处理连接了。

为了提升IO交互效率,避免阻塞傻等的情况发生。Java 1.4中引入了Java NIO,全称 Java non-blocking IO,提供了一系列改进的输入/输出的新特性,被统称为 NIO,即 New IO,是同步非阻塞的。

NIO 相关类都放在 java.nio 包下,并对原 java.io 包中很多类进行了改写。

NIO 有三大核心部分:Channel(管道)Buffer(缓冲区)Selector(选择器)

NIO 是面向缓冲区编程的。数据读取到了一个它稍微处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞的高伸缩性网络。

Java NIO 的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用数据,如果目前没有可用数据时,则说明都不会获取,而不是保持线程阻塞,所以直到数据变为可以读取之前,该线程可以做其他事情。非阻塞写入同理。

NIO

另外,Java NIO 是一种基于IO多路复用的IO模型,,而不是简单的同步非阻塞的IO模型。所谓IO多路复用指的就是用同一个线程处理大量连接,多路指的就是大量连接,复用指的就是使用一个线程来进行处理。

对于普通的同步非阻塞模型来说,NIO 的读写以及接受方法在等待数据就绪阶段都是非阻塞的。如上文中的描述,同步非阻塞模式下应用进程不断向内核发起调用(轮询的方式),询问内核数据完成准备。

相对于同步阻塞模型有了一定的优化,通过不断轮询数据是否准备好,避免了调用阻塞。但是由于应用不断进行系统IO调用,在此过程中十分消耗CPU,而JavaNIO正是借助于此实现了IO性能的提升。(这里以epoll机制来进行说明)

Java NIO基于通道和缓冲区的形式来处理流数据,借助于Linux操作系统的epoll机制,多路复用器 selector 就会不断进行轮询,当某个channel的事件(读事件,写事件,连接事件等等)准备就绪的时候,就是会找到这个channel对应的SelectionKey,去做相应的操作,进行数据的读写操作。

什么是 SelectionKey

SelectionKey 是一个抽象类,表示 selectableChannel 在 Selector 中注册的标识,每个 Channel 向 Selector 注册时,都将会创建一个 SelectionKey。SelectionKey 将 Channel 与 Selector 建立了关系,并维护了 channel 事件。

可以通过 cancel 方法取消键,取消的键不会立即从 selector 中移除,而是添加到 cancelledKeys 中,在下一次 select 操作时移除它,所以在调用某个 key 时,需要使用 isValid 进行校验。

image-20220125185242077

# 3.3 AIO

所谓AIO(Asynchronous IO)就是NIO第二代,它是在Java 7中引入的,是一种异步IO模型。异步IO模型是基于事件和回调机制实现的,当应用发起调用请求之后会直接返回不会阻塞在那里,当后台进行数据处理完成后,操作系统便会通知对应的线程来进行后续的数据处理。

从效率上来看,AIO 无疑是最高的,然而,美中不足的是目前作为广大服务器使用的系统 linuxAIO 的支持还不完善,导致我们还不能愉快的使用 AIO 这项技术,Netty实际也是使用过AIO技术,但是实际并没有带来很大的性能提升,目前还是基于Java NIO实现的。

# 4. 参考