高并发IO的底层原理-五种主要的IO模型

概述

服务器端高并发IO编程,往往要求的性能都非常高,一般情况下都需要选用高性能的IO模型。

常见的IO模型虽然有五种,但是可以分成四大类:

  1. 同步阻塞IO(Blocking IO)

首先,解释一下阻塞与非阻塞。阻塞IO,指的是需要内核IO操作彻底完成后,才返回到用户空间执行用户程序的操作指令,阻塞一词所指的是用户程序(发起IO请求的进程或者线程)的执行状态是阻塞的。可以说传统的IO模型都是阻塞IO模型,并且在Java中,默认创建的socket都属于阻塞IO模型。

其次,解释一下同步与异步。简单理解,同步与异步可以看成是发起IO请求的两种方式。同步IO是指用户空间(进程或者线程)是主动发起IO请求的一方,系统内核是被动接受方。异步IO则反过来,系统内核主动发起IO请求的一方,用户空间是被动接受方。

所谓同步阻塞IO,指的是用户空间(或者线程)主动发起,需要等待内核IO操作彻底完成后,才返回到用户空间的IO操作,IO操作过程中,发起IO请求的用户进程(或者线程)处于阻塞状态。

  1. 同步非阻塞NIO(Non-Blocking IO)

非阻塞IO,指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间去执行后续的指令,即发起IO请求的用户进程(或者线程)处于非阻塞的状态,与此同时,内核会立即返回给用户一个IO的状态值。

阻塞和非阻塞的区别是什么呢?

阻塞是指用户进程(或者线程)一直在等待,而不能干别的事情;非阻塞是指用户进程(或者线程)拿到内核返回的状态值就返回自己的空间,可以去干别的事情。在Java中,非阻塞IO的socket套接字,要求被设置为NONBLOCK模式。

所谓同步非阻塞NIO,指的是用户进程主动发起,不需要等待内核IO操作彻底完成之后,就能立即返回到用户空间的IO操作,IO操作过程中,发起IO请求的用户进程(或者线程)处于非阻塞状态。

  1. IO多路复用(IO Multiplexing)

为了提高性能,操作系统引入了一类新的系统调用,专门用于查询IO文件描述符的(含 socket连接)的就绪状态。在Linux系统中,新的系统调用为select/epoll系统调用。通过该系统调用,一个用户进程(或者线程)可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将文件描述符的就绪状态返回给用户进程(或者线程),用户空间可以根据文件描述符的就绪状态,进行相应的IO系统调用。

IO多路复用(IO Multiplexing)是高性能Reactor线程模型的基础IO模型,当然,此模型是建立在同步非阻塞的模型基础之上的升级版。

  1. 信号驱动IO模型

在信号驱动IO模型中,用户线程通过向核心注册IO事件的回调函数,来避免IO时间查询的阻塞。具体来说,用户进程预先在内核中设置一个回调函数,当某个事件发生时,内核使用信号(SIGIO)通知进程运行回调函数。然后进入IO操作的第二个阶段——执行阶段:用户线程会继续执行,在信号回调函数中调用IO读写操作来进行实际的IO请求操作。

信号驱动IO可以看成是一种异步IO,可以简单理解为系统进行用户函数的回调。只是,信号驱动IO的异步特性做的不彻底。为什么呢? 信号驱动IO仅仅在IO事件的通知阶段是异步的,而在第二阶段,也就是在将数据从内核缓冲区复制到用户缓冲区这个过程,用户进程是阻塞的、同步的。

5. 异步IO(Asynchronous IO)

异步IO,指的是用户空间与内核空间的调用方式大反转。用户空间的线程变成被动接受者,而内核空间成了主动调用者。在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户缓冲区内,内核在IO完成后通知用户线程直接使用即可。

异步IO类似于Java中典型的回调模式,用户进程(或者线程)向内核空间注册了各种IO事件的回调函数,由内核去主动调用。

异步IO包含两种:不完全异步的信号驱动IO模型和完全的异步IO模型。接下来,对以上的五种常见的IO模型进行一下详细的介绍。

同步阻塞IO(Blocking IO)

默认情况下,在Java应用程序进程中,所有对socket连接的进行的IO操作都是同步阻塞 IO(Blocking IO)。

在阻塞式IO模型中,Java应用程序从发起IO系统调用开始,一直到系统调用返回,在这段时间内,发起IO请求的Java进程(或者线程)是阻塞的。直到返回成功后,应用进程才能开始处理用户空间的缓存区数据。同步阻塞IO的具体流程,如图2-2所示。

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

  1. 从Java进行IO读后发起sys_read系统调用开始,用户线程(或者线程)就进入阻塞状态。
  2. 当系统内核收到sys_read系统调用,就开始准备数据。一开始,数据可能还没有到达内核缓冲区(例如,还没有收到一个完整的socket数据包),这个时候内核就要等待。
  3. 内核一直等到完整的数据到达,就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到用户缓冲区中的字节数)。
  4. 直到内核返回后,用户线程才会解除阻塞的状态,重新运行起来。

阻塞IO的特点是:在内核进行IO执行的两个阶段,发起IO请求的用户进程(或者线程)被阻塞了。

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

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

同步非阻塞NIO(None Blocking IO)

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

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

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

同步非阻塞IO的流程,如图2-3所示。

举个例子。发起一个非阻塞socket的sys_read读操作的系统调用,流程如下:

  1. 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以,为了读取到最终的数据,用户进程(或者线程)需要不断地发起IO系统调用。
  2. 内核数据到达后,用户进程(或者线程)发起系统调用,用户进程(或者线程)阻塞(大家一定要注意,此处用户进程的阻塞状态)。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区,然后内核返回结果(例如返回复制到的用户缓冲区的字节数)。
  1. 用户进程(或者线程)在读数据时,没有数据会立即返回而不阻塞,用户空间需要经过多次的尝试,才能保证最终真正读到数据,而后继续执行。

同步非阻塞IO的特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止。

同步非阻塞IO的优点:每次发起的IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。

同步非阻塞IO的缺点:不断地轮询内核,这将占用大量的CPU时间,效率低下。总体来说,在高并发应用场景中,同步非阻塞IO是性能很低的,也是基本不可用的,

一般Web服务器都不使用这种IO模型。在Java的实际开发中,也不会涉及这种IO模型。但是此模型还是有价值的,其作用在于,其他IO模型中可以使用非阻塞IO模型作为基础,以实现其高性能。

同步非阻塞 IO 也可以简称为 NIO,但是,它不是 Java 编程中的 NIO,虽然它们的英文缩写一样,但是不能混淆。Java 的 NIO(New IO)类库组件,所归属的不是基础 IO 模型中的 NIO(None Blocking IO)模型,而是另外的一种模型,叫做 IO 多路复用模型(IO Multiplexing)。

IO多路复用模型(IO Multiplexing)

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

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

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

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

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

  1. 选择器注册。在这种模式中,首先,将需要sys_read操作的目标文件描述符(socket 连接),提前注册到Linux的select/epoll选择器中,在Java中所对应的选择器类是Selector类。然后,才可以开启整个IO多路复用模型的轮询流程。
  1. 就绪状态的轮询。通过选择器的查询方法,查询所有的提前注册过的目标文件描述符(socket连接)的IO就绪状态。通过查询的系统调用,内核会返回一个就绪的socket列表。当任何一个注册过的socket中的数据准备好或者就绪了,就是内核缓冲区有数据了,内核就将该socket加入到就绪的列表中,并且返回就绪事件。
  1. 用户线程获得了就绪状态的列表后,根据其中的socket连接,发起sys_read系统调用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
  1. 复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了数据,继续执行。

IO多路复用模型的sys_read系统调用流程,如图2-4所示。

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

和NIO模型相似,多路复用IO也需要轮询。负责select/epoll状态查询调用的线程,需要不断地进行select/epoll轮询,查找出达到IO操作就绪的socket连接。

IO多路复用模型与同步非阻塞IO模型是有密切关系的,具体来说,注册在选择器上的每一个可以查询的socket连接,一般都设置成为同步非阻塞模型。只是这一点对于用户程序而言,是无感知的。

IO多路复用模型的优点:一个选择器查询线程,可以同时处理成千上万的网络连接,所以,用户程序不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开销。

这是一个线程维护一个连接的阻塞IO模式相比,使用多路IO复用模型的最大优势。通过JDK的源码可以看出,Java语言的NIO(New IO)组件,在Linux系统上,是使用的是select系统调用实现的。所以,Java语言的NIO(New IO)组件所使用的,就是IO多路复用模型。

IO多路复用模型的缺点:本质上,select/epoll系统调用是阻塞式的,属于同步阻塞IO。都需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个事件的查询过程是阻塞的。

如果彻底地解除线程的阻塞,就必须使用异步IO模型。

信号驱动IO模型(SIGIO、Signal Driven I/O)

在信号驱动IO模型中,用户线程通过向核心注册IO事件的回调函数,来避免IO时间查询的阻塞。

具体的做法是,用户进程预先在内核中设置一个回调函数,当某个事件发生时,内核使用信号(SIGIO)通知进程运行回调函数。 然后用户线程会继续执行,在信号回调函数中调用IO读写操作来进行实际的IO请求操作。

信号驱动IO的基本流程是:用户进程通过系统调用,向内核注册SIGIO信号的owner进程和以及进程内的回调函数。内核IO事件发生后(比如内核缓冲区数据就位)后,通知用户程序,用户进程通过sys_read系统调用,将数据复制到用户空间,然后执行业务逻辑。

信号驱动IO优势:用户进程在等待数据时,不会被阻塞,能够提高用户进程的效率。具体来说:在信号驱动式I/O模型中,应用程序使用套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。

信号驱动IO缺点:

  1. 在大量IO事件发生时,可能会由于处理不过来,而导致信号队列溢出。
  2. 信号驱动IO可以看成是一种异步IO,可以简单理解为系统进行用户函数的回调。只是,信号驱动IO的异步特性,又做的不彻底。为什么呢? 信号驱动IO仅仅在IO事件的通知阶段是异步的,而在第二阶段,也就是在将数据从内核缓冲区复制到用户缓冲区这个过程,用户进程是阻塞的、同步的。

如果要做彻底的异步IO,那就需要使用第五种IO模式:异步IO模式。

异步IO模型(Asynchronous IO)

异步IO模型(Asynchronous IO,简称为AIO)。AIO的基本流程是:用户线程通过系统调用,向内核注册某个IO操作。内核在整个IO操作(包括数据准备、数据复制)完成后,通知用户程序,用户执行后续的业务操作。

在异步IO模型中,在整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓冲区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。

异步IO模型的流程,如图2-5所示。

举个例子。发起一个异步IO的sys_read读操作的系统调用,流程如下:

  1. 当用户线程发起了sys_read系统调用(可以理解为注册一个回调函数),立刻就可以开始去做其他的事,用户线程不阻塞。
  2. 内核就开始了IO的第一个阶段:准备数据。等到数据准备好了,内核就会将数据从内核缓冲区复制到用户缓冲区。
  3. 内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调方法,告诉用户线程,sys_read系统调用已经完成了,数据已经读入到了用户缓冲区。

(4)用户线程读取用户缓冲区的数据,完成后续的业务操作。

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

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

理论上来说,异步IO是真正的异步输入输出,它的吞吐量高于IO多路复用模型的吞吐量。就目前而言,Windows系统下通过IOCP实现了真正的异步IO。而在Linux系统下,异步IO模型在2.6版本才引入,JDK的对其的支持目前并不完善,因此异步IO在性能上没有明显的优势。

大多数的高并发服务器端的程序,一般都是基于Linux系统的。因而,目前这类高并发网络应用程序的开发,大多采用IO多路复用模型。大名鼎鼎的Netty框架,使用的就是IO多路复用模型,而不是异步IO模型。

同步异步、阻塞非阻塞区别联系

首先同步和异步,是针对应用程序(如Java)与内核的交互过程的方向而言的。

同步类型的IO操作,发起方是应用程序,接收方是内核。同步IO由应用进程发起IO操作,并阻塞等待,或者轮询的IO操作是否完成。

异步IO操作,应用程序在提前注册完成回调函数之后去做自己的事情,IO交给内核来处理,在内核完成IO操作以后,启动进程的回调函数。

阻塞与非阻塞,关注的是用户进程在IO过程中的等待状态。

前者用户进程需要为IO操作去阻塞等待,而后者用户进程可以不用为IO操作去阻塞等待。同步阻塞型IO、同步非阻塞IO、多路IO复用,都是同步IO,也是阻塞性IO。

异步IO必定是非阻塞的,所以不存在异步阻塞和异步非阻塞的说法。真正的异步IO需要内核的深度参与。异步IO中的用户进程时候根本不去考虑IO的执行,IO操作主要交给内核去完成,而自己只等待一个完成信号。

小结

四种IO模型,基本上概况了当前主要的IO处理模型,理论上来说,从阻塞IO到异步IO,越往后,阻塞越少,效率也越优。在这四种IO模型中,前三种属于同步IO,因为真正的IO操作都将阻塞应用线程。

只有最后一种异步IO模型,才是真正的异步IO模型,可惜目前Linux操作系统或者说JDK的底层实现尚欠完善。不过,通过应用层优秀框架如Netty,同样能在IO多路复用模型的基础上,开发出具备支撑高并发(如百万级以上的连接)的服务器端应用。

原文链接:,转发请注明来源!