跳至主要內容

四种主要的 IO 模型

DHB大约 12 分钟网络IO

服务器端编程,经常需要构造高性能的网络应用,需要选用高性能的 IO 模型,这也是通关大公司面试必备的知识。

四种主要的 IO 模型

同步阻塞 IO(Blocking IO)

首先,解释一下这里的阻塞与非阻塞: 阻塞 IO,指的是需要内核 IO 操作彻底完成后,才返回到用户空间执行用户的操作。阻塞指的是用户空间程序的执行状态。传统的 IO 模型都是同步阻塞 IO。在 Java 中,默认创建的 socket 都是阻塞的。 其次,解释一下同步与异步: 同步 IO,是一种用户空间与内核空间的 IO 发起方式。同步 IO 是指用户空间的线程是主动发起 IO 请求的一方,内核空间是被动接受方。异步 IO 则反过来,是指系统内核是主动发起 IO 请求的一方,用户空间的线程是被动接受方。

在 Java 应用程序进程中,默认情况下,所有的 socket 连接的 IO 操作都是同步阻塞 IO(Blocking IO)。 在阻塞式 IO 模型中,Java 应用程序从 IO 系统调用开始,直到系统调用返回,在这段时间内,Java 进程是阻塞的。返回成功后,应用进程开始处理用户空间的缓存区数据。

img

举个例子,在 Java 中发起一个 socket 的 read 读操作的系统调用,流程大致如下:

(1)从 Java 启动 IO 读的 read 系统调用开始,用户线程就进入阻塞状态。

(2)当系统内核收到 read 系统调用,就开始准备数据。一开始,数据可能还没有到达内核缓冲区(例如,还没有收到一个完整的 socket 数据包),这个时候内核就要等待。

(3)内核一直等到完整的数据到达,就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到用户缓冲区中的字节数)。

(4)直到内核返回后,用户线程才会解除阻塞的状态,重新运行起来。

总之,阻塞 IO 的特点是:在内核进行 IO 执行的两个阶段,用户线程都被阻塞了。

阻塞 IO 的优点是:应用的程序开发非常简单;在阻塞等待数据期间,用户线程挂起。在阻塞期间,用户线程基本不会占用 CPU 资源。

阻塞 IO 的缺点是:一般情况下,会为每个连接配备一个独立的线程;反过来说,就是一个线程维护一个连接的 IO 操作。在并发量小的情况下,这样做没有什么问题。但是,当在高并发的应用场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上阻塞 IO 模型在高并发应用场景下是不可用的。

其次,解释一下同步与异步: 同步 IO,是一种用户空间与内核空间的 IO 发起方式。同步 IO 是指用户空间的线程是主动发起 IO 请求的一方,内核空间是被动接受方。异步 IO 则反过来,是指系统内核是主动发起 IO 请求的一方,用户空间的线程是被动接受方。

同步非阻塞 IO(Non-blocking IO)

非阻塞 IO,指的是用户空间的程序不需要等待内核 IO 操作彻底完成,可以立即返回用户空间执行用户的操作,即处于非阻塞的状态,与此同时内核会立即返回给用户一个状态值。 简单来说:阻塞是指用户空间(调用线程)一直在等待,而不能干别的事情;非阻塞是指用户空间(调用线程)拿到内核返回的状态值就返回自己的空间,IO 操作可以干就干,不可以干,就去干别的事情。 非阻塞 IO 要求 socket 被设置为 NONBLOCK。 强调一下,这里所说的 NIO(同步非阻塞 IO)模型,并非 Java 的 NIO(New IO)库。

socket 连接默认是阻塞模式,在 Linux 系统下,可以通过设置将 socket 变成为非阻塞的模式(Non-Blocking)。使用非阻塞模式的 IO 读写,叫作同步非阻塞 IO(None Blocking IO),简称为 NIO 模式。在 NIO 模型中,应用程序一旦开始 IO 系统调用,会出现以下两种情况:

(1)在内核缓冲区中没有数据的情况下,系统调用会立即返回,返回一个调用失败的信息。

(2)在内核缓冲区中有数据的情况下,是阻塞的,直到数据从内核缓冲复制到用户进程缓冲。复制完成后,系统调用返回成功,应用进程开始处理用户空间的缓存数据。

img

举个例子。发起一个非阻塞 socket 的 read 读操作的系统调用,流程如下: (1)在内核数据没有准备好的阶段,用户线程发起 IO 请求时,立即返回。所以,为了读取到最终的数据,用户线程需要不断地发起 IO 系统调用。 (2)内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到的用户缓冲区的字节数)。 (3)用户线程读到数据后,才会解除阻塞状态,重新运行起来。也就是说,用户进程需要经过多次的尝试,才能保证最终真正读到数据,而后继续执行。 同步非阻塞 IO 的特点:应用程序的线程需要不断地进行 IO 系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成 IO 系统调用为止。 同步非阻塞 IO 的优点:每次发起的 IO 系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。 同步非阻塞 IO 的缺点:不断地轮询内核,这将占用大量的 CPU 时间,效率低下。 总体来说,在高并发应用场景下,同步非阻塞 IO 也是不可用的。一般 Web 服务器不使用这种 IO 模型。这种 IO 模型一般很少直接使用,而是在其他 IO 模型中使用非阻塞 IO 这一特性。在 Java 的实际开发中,也不会涉及这种 IO 模型。 这里说明一下,同步非阻塞 IO,可以简称为 NIO,但是,它不是 Java 中的 NIO,虽然它们的英文缩写一样,希望大家不要混淆。Java 的 NIO(New IO),对应的不是四种基础 IO 模型中的 NIO(None Blocking IO)模型,而是另外的一种模型,叫作 IO 多路复用模型(IO Multiplexing)。

IO 多路复用(IO Multiplexing)

即经典的 Reactor 反应器设计模式,有时也称为异步阻塞 IO, Java 中的 Selector 选择器和 Linux 中的 epoll 都是这种模型。

如何避免同步非阻塞 IO 模型中轮询等待的问题呢?这就是 IO 多路复用模型。

在 IO 多路复用模型中,引入了一种新的系统调用,查询 IO 的就绪状态。在 Linux 系统中,对应的系统调用为 select/epoll 系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读 / 可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的 IO 系统调用。

目前支持 IO 多路复用的系统调用,有 select、epoll 等等。select 系统调用,几乎在所有的操作系统上都有支持,具有良好的跨平台特性。epoll 是在 Linux 2.6 内核中提出的,是 select 系统调用的 Linux 增强版本。

在 IO 多路复用模型中通过 select/epoll 系统调用,单个应用程序的线程,可以不断地轮询成百上千的 socket 连接,当某个或者某些 socket 网络连接有 IO 就绪的状态,就返回对应的可以执行的读写操作。

举个例子来说明 IO 多路复用模型的流程。发起一个多路复用 IO 的 read 读操作的系统调用,流程如下:

(1)选择器注册。在这种模式中,首先,将需要 read 操作的目标 socket 网络连接,提前注册到 select/epoll 选择器中,Java 中对应的选择器类是 Selector 类。然后,才可以开启整个 IO 多路复用模型的轮询流程。

(2)就绪状态的轮询。通过选择器的查询方法,查询注册过的所有 socket 连接的就绪状态。通过查询的系统调用,内核会返回一个就绪的 socket 列表。当任何一个注册过的 socket 中的数据准备好了,内核缓冲区有数据(就绪)了,内核就将该 socket 加入到就绪的列表中。当用户进程调用了 select 查询方法,那么整个线程会被阻塞掉。

(3)用户线程获得了就绪状态的列表后,根据其中的 socket 连接,发起 read 系统调用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。

(4)复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了数据,继续执行。

img

IO 多路复用模型的特点:IO 多路复用模型的 IO 涉及两种系统调用(System Call),另一种是 select/epoll(就绪查询),一种是 IO 操作。IO 多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用 select/epoll。

和 NIO 模型相似,多路复用 IO 也需要轮询。负责 select/epoll 状态查询调用的线程,需要不断地进行 select/epoll 轮询,查找出达到 IO 操作就绪的 socket 连接。 IO 多路复用模型与同步非阻塞 IO 模型是有密切关系的。对于注册在选择器上的每一个可以查询的 socket 连接,一般都设置成为同步非阻塞模型。仅是这一点,对于用户程序而言是无感知的。 IO 多路复用模型的优点:与一个线程维护一个连接的阻塞 IO 模式相比,使用 select/epoll 的最大优势在于,一个选择器查询线程可以同时处理成千上万个连接(Connection)。系统不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。

Java 语言的 NIO(New IO)技术,使用的就是 IO 多路复用模型。在 Linux 系统上,使用的是 epoll 系统调用。 IO 多路复用模型的缺点:本质上,select/epoll 系统调用是阻塞式的,属于同步 IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞的。 如何彻底地解除线程的阻塞,就必须使用异步 IO 模型。

异步 IO(Asynchronous IO)

异步 IO,指的是用户空间与内核空间的调用方式反过来。用户空间的线程变成被动接受者,而内核空间成了主动调用者。这有点类似于 Java 中比较典型的回调模式,用户空间的线程向内核空间注册了各种 IO 事件的回调函数,由内核去主动调用。

举个例子。发起一个异步 IO 的 read 读操作的系统调用,流程如下: (1)当用户线程发起了 read 系统调用,立刻就可以开始去做其他的事,用户线程不阻塞。 (2)内核就开始了 IO 的第一个阶段:准备数据。等到数据准备好了,内核就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存)。 (3)内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调接口,告诉用户线程 read 操作完成了。 (4)用户线程读取用户缓冲区的数据,完成后续的业务操作。

img

异步 IO 模型的特点:在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接收内核的 IO 操作完成的事件,或者用户线程需要注册一个 IO 操作完成的回调函数。正因为如此,异步 IO 有的时候也被称为信号驱动 IO。

异步 IO 异步模型的缺点:应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持。

理论上来说,异步 IO 是真正的异步输入输出,它的吞吐量高于 IO 多路复用模型的吞吐量。 就目前而言,Windows 系统下通过 IOCP 实现了真正的异步 IO。而在 Linux 系统下,异步 IO 模型在 2.6 版本才引入,目前并不完善,其底层实现仍使用 epoll,与 IO 多路复用相同,因此在性能上没有明显的优势。 大多数的高并发服务器端的程序,一般都是基于 Linux 系统的。因而,目前这类高并发网络应用程序的开发,大多采用 IO 多路复用模型。 大名鼎鼎的 Netty 框架,使用的就是 IO 多路复用模型,而不是异步 IO 模型。

整理自《Netty、Redis、Zookeeper 高并发实战》

上次编辑于:
贡献者: dhb