IO模型浅析

注:
1)本人非科班出身,文章的来源主要是基于一些能找到的资料,在理解的基础上做一些总结归纳,以期对IO相关的知识体系化。水平有限,还请指教!
2)本文后半部分对IO所涉及的一些概念做了简要概述,如果读者从头开始阅读的情况下感觉一头雾水,可以先去阅读并熟悉一些基本的概念,有了基本的上下文概念后,再从头看起。

Linux的网络IO模型

网络IO的本质是socket的读写,socket在Linux中被抽象为流,IO可以理解为对流的操作。

注意缓存IO这一概念

事先说明:

  • IO本身可以分为内存IO、网络IO和磁盘IO三种,一般讨论IO时更多是指后两者,尤其是网络IO。

  • 阻塞/非阻塞:针对函数/方法的实现方式而言

    即数据就绪之前是立刻返回还是等待,即发起IO请求后是否会阻塞。

  • 同步/异步

    IO读操作指数据流经:网络 -> 内核缓冲区 -> 用户内存

    而同步和异步的主要区别在于数据从 内核缓冲区 -> 用户内存 这个过程需不需要用户进程等待。

对于一个网络IO,会涉及到两个系统对象,一个是调用这个IO的process(or thread),另一个就是系统内核(kernel)

当一个read操作发生时,它会经历两个阶段:

  1. 第一阶段:等待数据准备 (Waiting for the data to be ready)。
  2. 第二阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。

对于socket流而言,

  1. 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。
  2. 第二步:把数据从内核缓冲区复制到应用进程缓冲区。

网络应用处理的是两大类问题:网络IO、数据计算。前者给应用带来的性能瓶颈更大。

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

  • 同步模型(synchronous IO)
  • 阻塞IO模型(blocking IO)
  • 非阻塞IO模型(non-blocking IO)
  • 多路复用IO模型(multiplexing IO)
  • 信号驱动IO模型(signal-driven IO)
  • 异步IO(asynchronous IO)

Blocking IO

在Linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程如下:

[图片上传失败...(image-9e85ea-1527599992824)]

当用户进程调用了recvfrom这个系统调用,如上所述,会有两个阶段

  • 准备数据。很多时候数据在一开始还没有到达,这个时候kernel就要等待足够的数据到来。而用户进程会一直阻塞。
  • 当kernel等到数据准备好了,它会将数据从kernel中拷贝到用户内存,然后kernel返回,用户进程结束block状态,重新运行。

Blocking IO的特点就是IO执行的两个阶段都是block了的。

Non-Blocking IO

在Linux中,可以通过设置socket使其变为non-blocking,其流程如下:

[图片上传失败...(image-b07607-1527599992824)]

当用户进程调用了recvfrom这个系统调用,如果kernel中的数据还没有准备好,那么用户进程不会block而是立刻返回一个error,即从用户的角度而言,不需要等待,马上得到一个结果。从图中可以看出,用户进程在判断结果是一个error后,了解到数据还没有准备好,于是就不断重复上述操作直至kernel中的数据准备好,然后它马上将数据拷贝到了用户内存,然后返回。

Multiplexing IO

Select/Epoll,也被称作是Event-Driven IO。好处是单个process可以同时处理多个网络连接的IO。

基本原理可见下面的“IO复用技术”。也叫多路IO就绪通知。这是一种进程预先告知内核的能力,让内核发现进程指定的一个或多个IO条件就绪了,就通知进程。使得一个进程能在一连串的事件上等待。

[图片上传失败...(image-f4d1c4-1527599992824)]

这个流程和Blocking IO的流程其实并没有太多不同,事实上仅从图中看起来,由于需要进行两次系统调用,可能更差一些。但是,Select的优势在于它可以同时处理多个连接。

如果处理的连接数不是很高的话,使用“Select/Epoll 的 Web Server”不一定比使用“多线程 + BIO的Web Server”性能更好,反而延迟会更大。

Select/Epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

Asynchronous IO

[图片上传失败...(image-2e105e-1527599992824)]

用户进程发起read操作之后,立刻就可以开始去做其它的事。

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

比较

[图片上传失败...(image-89e729-1527599992824)]

IO复用技术

在IO编程过程中,当需要处理多个请求时,可以使用 多线程 和 IO复用 的方式进行处理。

IO复用是什么?

把多个IO的阻塞复用到一个select之类的阻塞上,从而使得系统在单线程的情况下同时支持处理多个请求。

IO复用常见的应用场景

  • 服务器需要同时处理多个处于监听状态和多个连接状态的套接字;
  • 服务器需要处理多种网络协议的套接字

IO复用的实现方式目前主要有select、poll和epoll。

select和poll的原理基本相同

  • 注册待侦听的fd(这里的fd创建时最好使用非阻塞)
  • 每次调用都去检查这些fd的状态,当有一个或者多个fd就绪的时候返回
  • 返回结果中包括已就绪和未就绪的fd

Linux网络编程过程中,相比于select/poll,epoll是有着更明显优势的一种选择

  1. 支持一个进程打开的socket描述符不受限制(仅受限于操作系统的最大文件句柄数)。

    Select的缺陷:一个进程所打开的FD受限,默认是2048;尽管数值可以更改,但同样可能导致网络效率下降;可以选择多进程的解决方案,但是进程的创建本身代价不小,而且进程间数据同步远比不上线程间同步的高效。

    epoll所支持的FD上限是最大可以打开文件的数目, /proc/sys/fs/file-max

  2. IO效率可能随着文件描述符数目的增加而线性下降。

    select/poll是线性扫描FD的集合;epoll是根据FD上面的回调函数实现的,活跃的socket会主动去调用该回调函数,其它socket则不会,相当于市是一个AIO,只不过推动力在OS内核。

  3. 使用mmap加速内核与用户空间的消息传递。

    zero-copy的一种。

  4. epoll的API更加简单。

IO复用还有一个 水平触发 和 边缘触发 的概念:

  • 水平触发:当就绪的fd未被用户进程处理后,下一次查询依旧会返回,这是select和poll的触发方式。
  • 边缘触发:无论就绪的fd是否被处理,下一次不再返回。理论上性能更高,但是实现相当复杂,并且任何意外的丢失事件都会造成请求处理错误。epoll默认使用水平触发,通过相应选项可以使用边缘触发。

Java的IO模型

  • 传统的BIO模型
  • 伪异步
  • NIO
  • AIO

BIO

BIO是一个典型的网络编程模型,是通常我们实现一个服务端程序的过程。

步骤如下:

  • 主线程accept请求阻塞
  • 请求到达,创建新的线程来处理这个套接字,完成对客户端的响应。
  • 主线程继续accept下一个请求

这种模型有一个很大的问题是:当客户端连接增多时,服务端创建的线程也会暴涨,系统性能会急剧下降。

伪异步

在BIO模型的基础上,类似于 tomcat的bio connector,采用的是线程池来避免对于每一个客户端都创建一个线程:把请求抛到线程池中异步等待处理。

NIO

NIO API主要是三个部分:缓冲区(Buffers)、通道(Channels)和 Selector。

NIO基于事件驱动思想来实现的,它采用Reactor模式实现,主要用来解决BIO模型中一个服务端无法同时并发处理大量客户端连接的问题。

NIO基于Selector进行轮训,当socket有数据可读、可写、连接完成、新的TCP请求接入事件时,操作系统内核会触发Selector返回准备就绪的SelectionKey集合,通过SelectableChannel进行读写操作。

由于jdk的Selector底层基于epoll实现,理论上可以同时处理操作系统最大文件句柄个数的连接。SelectableChannel的读写操作都是异步非阻塞的,当由于数据没有就绪导致读半包时,立即返回,不会同步阻塞等待数据就绪,当TCP缓冲区数据就绪之后,会触发Selector的读事件,驱动下一次读操作。因此,一个Reactor线程就可以同时处理N歌客户端的连接,使得Java服务器的并发读写能力得到极大的提升。

JDK1.4开始引入了NIO类库,这里的NIO指的是Non-block IO,主要是使用Selector多路复用器来实现。Selector在Linux等主流操作系统上是通过epoll实现的。

NIO的实现流程,类似于select:

  • 创建ServerSocketChannel监听客户端连接并绑定监听端口,设置为非阻塞模式。
  • 创建Reactor线程,创建多路复用器(Selector)并启动线程。
  • 将ServerSocketChannel注册到Reactor线程的Selector上。监听accept事件。
  • Selector在线程run方法中无线循环轮询准备就绪的Key。
  • Selector监听到新的客户端接入,处理新的请求,完成tcp三次握手,建立物理连接。
  • 将新的客户端连接注册到Selector上,监听读操作。读取客户端发送的网络消息。
  • 客户端发送的数据就绪则读取客户端请求,进行处理。

相比BIO,NIO的编程非常复杂。

AIO

JDK1.7引入NIO2.0,提供了异步文件通道和异步套接字通道的实现。其底层在windows上是通过IOCP,在Linux上是通过epoll来实现的(LinuxAsynchronousChannelProvider.java,UnixAsynchronousServerSocketChannelImpl.java)。

  • 创建AsynchronousServerSocketChannel,绑定监听端口
  • 调用AsynchronousServerSocketChannel的accpet方法,传入自己实现的CompletionHandler。包括上一步,都是非阻塞的
  • 连接传入,回调CompletionHandler的completed方法,在里面,调用AsynchronousSocketChannel的read方法,传入负责处理数据的CompletionHandler。
  • 数据就绪,触发负责处理数据的CompletionHandler的completed方法。继续做下一步处理即可。
  • 写入操作类似,也需要传入CompletionHandler。

其编程模型相比NIO有了不少的简化。

. 同步阻塞IO 伪异步IO NIO AIO
客户端数目 :IO线程 1 : 1 m : n m : 1 m : 0
IO模型 同步阻塞IO 同步阻塞IO 同步非阻塞IO 异步非阻塞IO
吞吐量
编程复杂度 简单 简单 非常复杂 复杂

概念

同步异步/阻塞非阻塞 角度一

同步和异步

这两个概念与消息的通知机制 (synchronous communication/ asynchronous communication)有关。

[图片上传失败...(image-8a971a-1527599992824)]

同步:

一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么都成功,要么都失败,两个任务的状态可以保持一致。

​ 在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。

​ 对于同步型的调用,应用层需要自己去向系统内核问询,如果数据还未读取完毕,那此时读取文件的任务还未完成,应用层根据其阻塞和非阻塞的划分,或挂起或去做其他事情(所以同步和异步并不决定其等待数据返回时的状态);如果数据已经读取完毕,那此时系统内核将数据返回给应用层,应用层即可以用取得的数据做其他相关的事情。

异步:

不需要等到被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。

​ 调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者*通过状态、通知来通知调用者,或通过回调函数处理这个调用。

​ 而对于异步型的调用,应用层无需主动向系统内核问询,在系统内核读取完文件数据之后,会主动通知应用层数据已经读取完毕,此时应用层即可以接收系统内核返回过来的数据,再做其他事情。

也就是说,是否是同步还是异步,关注的是任务完成时消息通知的方式。由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式是异步调用。

消息的三种通知机制:状态、通知和回调

前者低效,后两者高效、类似。

阻塞与非阻塞

这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关。

[图片上传失败...(image-5c552e-1527599992824)]

阻塞调用:

​ 是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。函数只有在得到结果之后才会返回。

非阻塞调用:

​ 和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回去完成其他任务。

总结来说,是否是阻塞还是非阻塞,关注的是接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的,可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。

阻塞和同步的讨论

有人也许会把阻塞调用和同步调用等同起来,实际上它们是不同的。**

1、对于同步调用来说,很多时候当前线程可能还是激活的,只是从逻辑上当前函数没有返回而已,此时,这个线程可能也会处理其他的消息。还有一点,在这里先扩展下:

(a) 如果这个线程在等待当前函数返回时,仍在执行其他消息处理,那这种情况就叫做同步非阻塞;

(b) 如果这个线程在等待当前函数返回时,没有执行其他消息处理,而是处于挂起等待状态,那这种情况就叫做同步阻塞;

所以同步的实现方式会有两种:同步阻塞、同步非阻塞;同理,异步也会有两种实现:异步阻塞、异步非阻塞;

2、对于阻塞调用来说,则当前线程就会被挂起等待当前函数返回;

虽然表面上看非阻塞的方式可以明显的提高CPU的利用率,但是也带了另外一种后果就是系统的线程切换增加。增加的CPU执行时间能不能补偿系统的切换成本需要好好评估。

同步异步/阻塞非阻塞 角度二

[图片上传失败...(image-3a670c-1527599992824)]

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。Stevens给出的定义(其实是POSIX的定义)是这样子的:

​ **A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes; **

An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO

有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

同步异步/阻塞非阻塞 角度三

"同步异步"和"阻塞非阻塞"是两个不同范围的概念。

synchronous / asynchronous is to describe the relation between two modules.
blocking / non-blocking is to describe the situation of one module.

An example:
"I": a
"bookstore" : b

a asks b: do you have a book named "c++ primer"?

  1. blocking: before b answers a, a keeps waiting there for the answer. Now a (one module) is blocking. a and b are two threads or two processes or one thread or one process? we DON'T know.

  2. non-blocking: before b answers a, a just leaves there and every two minutes, a comes here for looking for the answer. Here a (one module) is non-blocking. a and b are two threads or two processes or one process? we DON'T know. BUT we are sure that a and b couldn't be one thread.

  3. synchronous: before b answers a, a keeps waiting there for the answer. It means that a can't continue until b finishes its job. Now we say: a and b (two modules) is synchronous. a and b are two threads or two processes or one thread or one process? we DON'T know.

  4. asynchronous: before b answers a, a leaves there and a can do other jobs. When b gets the answer, b will call a: hey! I have it! Then a will come to b to get the book when a is free. Now we say: a and b (two modules) is asynchronous. a and b are two threads or two processes or one process? we DON'T know. BUT we are sure that a and b couldn't be one thread.

同步异步/阻塞非阻塞 角度四

同步和异步是一个非常广的概念,它们的重点在于多个任务和事件发生时,一个事件的发生或执行是否会导致整个流程的暂时等待。

阻塞和非阻塞的区别关键在于当发出请求一个操作时,如果条件不满足,是会一直等待还是返回一个标志信息。

在讨论IO(硬盘、网络、外设)时,一个完整的IO读请求操作包括两个阶段:

1)查看数据是否就绪;

2)进行数据拷贝(内核将数据拷贝到用户线程)

阻塞(blocking IO)和非阻塞(non-blocking IO)的区别就在于第一个阶段,如果数据没有就绪,在查看数据是否就绪的过程中是一直等待,还是直接返回一个标志信息。

在《Unix网络编程》一书中对同步IO和异步IO的定义是这样的:

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes.
  An asynchronous I/O operation does not cause the requesting process to be blocked.

事实上,同步IO和异步IO模型是针对用户线程和内核的交互来说的:

同步IO:当用户发出IO请求操作之后,如果数据没有就绪,需要通过用户线程或者内核不断地去轮询数据是否就绪,当数据就绪时,再将数据从内核拷贝到用户线程;

异步IO:只有IO请求操作的发出是由用户线程来进行的,IO操作的两个阶段都是由内核自动完成,然后发送通知告知用户线程IO操作已经完成。也就是说在异步IO中,不会对用户线程产生任何阻塞。

这是同步IO和异步IO关键区别所在,同步IO和异步IO的关键区别反映在数据拷贝阶段是由用户线程完成还是内核完成。所以说<u>异步IO必须要有操作系统的底层支持</u>。

注意同步IO和异步IO与阻塞IO和非阻塞IO是不同的两组概念。

阻塞IO和非阻塞IO是反映在当用户请求IO操作时,如果数据没有就绪,是用户线程一直等待数据就绪,还是会收到一个标志信息这一点上面的。也就是说,阻塞IO和非阻塞IO是反映在IO操作的第一个阶段,在查看数据是否就绪时是如何处理的。

同步/异步 与 阻塞/非阻塞

同步阻塞

效率是最低的,

异步阻塞

异步操作是可以被阻塞住的,只不过它不是在处理消息时阻塞,而是在等待消息通知时被阻塞。

同步非阻塞

实际上是效率低下的,

这个程序<u>需要在两种不同的行为之间来回的切换</u>,效率可想而知是低下的。

异步非阻塞

效率更高。

用户空间与内核空间

现在操作系统都是采用虚拟存储器,对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。

为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,<u>一部分为内核空间,一部分为用户空间</u>。

针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换/任务切换/上下文切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

缓存 IO

缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 IO 的缺点:

数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

IO - 同步,异步,阻塞,非阻塞

聊聊Linux 五种IO模型

聊聊同步、异步、阻塞与非阻塞

推荐阅读更多精彩内容