io的底层原理

214 阅读21分钟

1.1 io读写的基础原理

  • 为了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两部分,一部分是内核空间(Kernel-Space),一部分是用户空间(User-Space)
    • 在Linux系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态
  • 用户程序进行IO的读写,依赖于底层的IO读写,基本上会用到底层的两大系统调用:sys_read&sys_write。虽然在不同的操作系统中,sys_read&sys_write两大系统调用的名称和形式可能不完全一样,但是他们的基本功能是一样的
  • 上层应用通过操作系统的sys_read系统调用,是把数据从内核缓冲区复制到应用程序的用户缓冲区;sys_write系统调用,是把数据从应用程序的用户缓冲区复制到操作系统内核缓冲区

1.2 内核缓冲区与进程(也叫用户)缓冲区

  • 缓冲区的目的是为了减少频繁的与设备之间的物理交换。
    • 计算机的外部物理设备与内存与CPU相比,有着非常大的差距,外部设备的直接读写,设计操作系统的中断。发生系统终端时,需要保存之间的进程数据和状态等信息,而结束中断后,还需要恢复之前的进程数据和状态等信息。为了减少底层系统的频繁中断所导致的时间损耗,性能损耗,于是出现了内核缓冲区
  • 有了内核缓冲区,操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量的时候,再进行IO设备的中断处理,集中执行物理设备的实际IO操作,通过这种机制来提升系统的性能
    • 具体什么时候中断,由操作系统决定,应用程序不需要关心
  • 上层应用程序使用sys_read 系统调用时,仅仅把数据从内核缓冲区复制到上层应用的缓冲区(用户缓冲区);上层应用使用sys_write系统调用时,仅仅把数据从应用的用户缓冲区复制到内核缓冲区中

1.3 流程

一次完整的socket请求和相应的数据交换流程2为

  1. 客户端发送请求:客户端程序通过sys_write系统调用,将数据复制到内核缓冲区,Linux将内核缓冲区的请求数据通过网卡发送出去
  2. 服务端系统接收数据:在服务端,这份请求数据会被服务端操作系统通过DMA硬件,从接收网卡中读取到服务器的内核缓冲区
  3. 服务端程序获取数据:程序通过sys_read系统调用,从内核缓冲区复制数据,复制到用户缓冲区
  4. 服务端程序业务处理:服务器在自己的用户空间中,完成客户端的请求所对应的业务处理
  5. 服务端程序返回数据:服务器程序完成处理后,构建好响应数据,调用sys_write系统调用,将这些数据从用户缓冲区写入内核缓冲区,操作系统负责将内核缓冲区的数据发送出去
  6. 服务端系统发送数据:服务器端系统被将内核缓冲区中的数据写入网卡,网卡通过底层的通信协议,将数据发送给目标客户端

2.1 五种主要的io模型

阻塞与非阻塞
  • 阻塞IO,指的是需要内核IO操作彻底完成后,才返回到用户空间执行用户程序的操作指令,阻塞一词所指的是用户程序(发起IO请求的进程或者线程)的执行状态是阻塞的
  • 非阻塞IO,指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间去执行后续的指令,即发起IO请求的用户进程(或者线程)处于非阻塞状态,与此同时,内核会立即返回给用户一个IO的状态值
  • 区别
    • 阻塞是指用户进程(或者线程)一直在等待,而不能去做别的事情
    • 非阻塞是值用户进程(或者线程)拿到内核返回的状态值就返回自己的空间,可以去干别的事情。在Java中,非阻塞IO的socket套接字,要求被设置为NONBLOCK模式
同步与异步
  • 同步io是指用户空间(进程或者线程)是主动发起io请求的一方,系统内核是被动接受方
  • 异步io是系统内核主动发起io请求,用户空间是 被动接受方

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

2.1.1 同步阻塞IO(Blocking IO)

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

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

举个例子,在java中发起一个socket的sys_read读操作的系统调用,流程为:

  1. 从java进行IO读后发起sys_read系统调用开始,用户进程(或者线程)就进入阻塞状态
  2. 当系统内核收到sys_read系统调用,就开始准备数据。一开始数据可能还没有到达内核缓冲区(例如没有一个完整的socket数据包),这个时候内核就要等待
  3. 内核一直等到完整的数据到达,就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到用户缓冲区中的字节数)
  4. 直到内核返回后,用户线程才会解除阻塞的状态,重新运行起来
  • 阻塞IO的特点是: 在内核进行IO执行的两个阶段,发起IO请求的用户进程(或者线程)被阻塞了
  • 优点是:应用程序开发简单;阻塞等待期间,用户线程挂起,基本不占用CPU资源
  • 缺点是:一般情况下,会为每个连接配置一个独立的线程,一个线程维护一个链接的IO操作。并发小时没什么问题,高并发的应用场景下,需要大量的线程来维护大量的网络连接,内存,线程切换开销会非常巨大。在高并发应用场景中,阻塞IO模型是性能很低的,基本上不可用

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

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

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

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

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

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

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

  1. 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以为了读取到最终的数据,用户进程(或者线程)需要不断的发起IO系统调用
  2. 内核数据到达后,用户进程(或者线程)发起系统调用,用户进程(或者线程)阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区,然后内核返回结果
  3. 用户进程(或者线程)在读数据时,没有数据会立即返回而不阻塞,用户空间需要经过多次尝试,才能保证最终真正读取到数据,而后继续执行
  • 同步非阻塞IO的特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止
  • 优点: 每次发起的IO系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好
  • 缺点:不断地轮询内核,占用大量CPU时间,效率低下

总体来说,高并发场景中,同步非阻塞IO是性能很低的,也是基本不可用的,一般Web服务器都不适用这种IO模型。java开发中,也不涉及这种IO模型,他的作用就是作为基础,用来实现其他IO模型。。。。。

2.1.3 IO多路复用(IO Multiplexing)

为了提高性能,操作系统引入了一类新的系统调用,专门用于查询IO文件描述符的(含Socket链接)的就绪状态。

在linux系统中,新的系统调用为select/epoll系统调用。通过该系统调用,一个用户进程(或者线程)可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读可写),内核能够将文件描述符的就绪状态返回给用户进程(或者线程),用户空间可以根据文件描述符的就绪状态,进行相应的IO系统调用

IO多路复用(IO multiplexing)是高性能Reactor线程模型的基础IO模型,此模型是建立在同步非阻塞的模型基础上的升级版,避免了同步阻塞IO模型中的轮询等待问题

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

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

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

举个例子说明流程,发起一个多路复用IO的socket的sys_read读操作的系统调用

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

  • IO多路复用模型的特点:
    • IO多路复用模型的IO涉及两种系统调用,一种是IO操作的系统调用,另一种是select/epoll就绪查询系统调用。
      • IO多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用select/epoll
    • 和NIO模型相似,多路复用IO也需要轮询。负责select/epoll状态查询调用的线程,需要不断地进行select/epoll轮询,查找出达到IO操作就绪的socket连接
    • IO多路复用模型与同步非阻塞IO模型是有密切关系的,具体来说,注册在选择器上的每一个可以查询的socket连接,一般都设置成为同步非阻塞模型。这一点对于用户程序无感知
  • IO多路复用模型的优点:
    • 一个选择器查询线程,可以同时处理成千上万的网络连接,所以用户程序不必创建大量的线程,也不必维护这些线程,从而大大减少了系统的开销。这与一个线程维护一个连接的阻塞IO模式相比,最大的优势
    • Java语言的NIO(New IO)组件,在linux系统上使用的是select系统调用实现。所以,Java的NIO就是IO多路复用
  • IO多路复用模型的缺点:
    • 本质上,select/epoll系统调用是阻塞式的,属于同步阻塞IO。需要在读写事件就绪后,由系统调用本身负责进行读写,也就是这个事件的查询过程是阻塞的

2.1.4 信号驱动IO模型

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

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

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

基本流程是:

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

信号驱动IO模型,每当套接字发生IO事件时,系统内核都会向用户进程发送SIGIO事件,所以,一般用于UDP传输,在TCP套接字的开发过程中很少使用,原因是SIGIO信号产生得过于频繁,并且内核发送的SIGIO信号,并没有告诉用户进程发生了什么IO事件

但是在UDP套接字上,通过SIGIO信号进行下面两个事件的类型判断即可:

  1. 数据包到达套接字
  2. 套接字上发生错误

因此,在SIGIO出现的时候,用户进程很容易进行判断和做出对应的处理:如果不是发生错误,那么就是有数据包到达了

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

  1. 设置SIGIO信号的信号处理回调函数
  2. 设置该套接口的当前进程,使得套接字的IO事件发生时,系统能够将SIGIO信号传递给当前进程
  3. 开启该套接口的信号驱动IO机制,通常通过使用fcntl方法的F_SETFL操作命令,使能(enable)套接字的O_NONBLOCK非阻塞标志和O_ASYNC异步标志完成

完成以上三步,用户进程就完成了事件回调处理函数的设置。当文件描述符上有事件发生时,SIGIO的信号处理函数将被触发,然后便可对目标文件描述符执行IO操作。

信号驱动IO优点:

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

信号驱动IO缺点:

  1. 大量IO事件发生时,可能会由于处理不过来,导致信号队列溢出
  2. 对于处理UDP套接字来讲,对于信号驱动IO是有用的。可是,对于TCP而言,由于SIGIO信号通知的条件众多,进行IO信号进一步区分的成本太高,信号驱动IO方式近乎无用
  3. 信号驱动IO可以看成是一种异步IO,可以简单理解为系统进行用户函数的回调,只是,信号驱动IO仅仅在IO事件的通知阶段是异步的,而在第二阶段,将数据从内核缓冲区复制到用户缓冲区这个过程,用户进程是阻塞,同步的

2.1.5 异步IO (Asynchronous IO)

异步IO包含两种:不完全异步的信号驱动IO模型和完全的异步IO模型

异步IO,指的是用户空间与内核空间的调用方式大反转。用户空间的线程变成被动接受者,而内核空间成了主动调用者。

在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户缓冲区内,内核在IO完成后通知用户线程直接使用即可。

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

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

基本流程为

  1. 用户线程通过系统调用,向内核注册某个IO操作
  2. 内核在整个IO操作(包括数据准备,数据复制)完成后,通知用户程序,用户执行后续的业务操作

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

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

异步IO模型的特点:

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

异步IO模型的缺点:

  1. 应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说,需要底层内核提供支持

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

2.2 同步异步,阻塞非阻塞的区别联系

  1. 同步和异步是针对应用程序与内核的交互过程的方向而言
    1. 同步类型的IO操作,发起方是应用程序,接受方是内核
    2. 同步IO由应用进程发起IO操作,并阻塞等待,或者轮询的IO操作是否完成
    3. 异步IO操作,应用程序在提前注册完成回调函数之后去做自己的事情,IO交给内核来处理,在内核完成IO操作以后,启动进程的回调函数
  1. 阻塞与非阻塞,关注的是用户进程在IO过程中的等待状态。前者用户进程需要为IO操作去阻塞等待,而后者用户进程可以不用为IO操作去阻塞等待。同步阻塞型IO,同步非阻塞型IO,多路复用IO,都是同步IO,也是阻塞型IO
    1. 异步IO必定是非阻塞的,所以不存在异步阻塞和异步非阻塞的说法。真正的异步IO需要内核的深度参与。异步IO中的用户进程根本不去考虑IO的执行,IO操作主要交给内核去完成,而自己只等待一个完成信号