【I/O设计总结一】五种I/O模型总结

前言

在了解IO设计之前,我们首先的搞明白几个概念:什么是阻塞和非阻塞,什么是同步和异步;

  1. 同步和异步是针对应用程序和内核的交互而言的;

    • 同步指的是应用程序触发IO操作并等待或者轮询的去查看IO操作是否就绪,也就是需要应用程序自己主动去查看。
    • 异步是指应用程序触发IO操作以后便开始做自己的事情,而当IO操作已经完成的时候会得到IO完成的通知,或者调用应用程序注册的回调函数。也就是说应用程序可以等着被通知。
  2. 阻塞和非阻塞描述的是用户线程调用内核IO操作的方式;

    • 阻塞方式下,应用程序调用读取或者写入函数后将一直阻塞等待着,IO操作需要彻底完成后,才会返回到用户空间。
    • 非阻塞方式下,应用程序调用读取或者写入函数后内核,会立即返回给应用程序一个状态值,表示数据是否准备好,而不是一直将应用程序阻塞着,直到IO操作彻底完成。

Unix的一个输入操作一般有两个不同的阶段:
1、等待数据准备好。
2、从内核到进程拷贝数据。

对于一个套接口上的输入操作,第一步一般是等待数据到达网络,当分组到达时,它被拷贝到内核中的某个缓冲区,第二步是将数据从内核缓冲区拷贝到应用缓冲区。

于是,同步/非同步,阻塞/非阻塞这两个维度的排列组合,便构成了我们今天常见的I/O设计模型:同步阻塞,同步非阻塞,异步阻塞,异步非阻塞IO

另外,Richard Stevens 在《Unix 网络编程》卷1中提到的基于信号驱动的IO(Signal Driven IO)模型,由于该模型并不常用,本文不作太多。

  1. 阻塞式I/O(BIO)
  2. 非阻塞式I/O(NIO)
  3. I/O多路复用模型(IO multiplexing,即select,poll和epoll)
  4. 信号驱动I/O(SIGIO)
  5. 异步I/O(Posix.1的aio_系列函数)

1 阻塞式I/O模型(BIO)

在此种方式下,用户进程在发起一个IO操作以后,必须等待IO操作的完成,只有当真正完成了IO操作以后,用户进程才能运行。JAVA传统的IO模型属于此种方式!

应用程序调用一个IO函数,导致应用程序阻塞,如果数据已经准备好,则从内核拷贝到用户空间,否则一直等待下去。

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

当用户进程调用了recvfrom这个系统函数,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。

而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

2 非阻塞式I/O模型(nonblocking IO)

我们把一个套接口设置为非阻塞就是告诉内核,当所请求的I/O操作无法完成时,不要将进程睡眠,而是返回一个错误。

这样我们的I/O操作函数将不断的轮询数据是否已经准备好,如果没有准备好,继续询问,直到数据准备好为止。在这个不断轮询的过程中,会大量的占用CPU的时间。

非阻塞式IO虽然不会让进程阻塞,可以在数据未完备的时候让进程返回,但应用进程会连续不断地查询内核,看看某操作是否准备好,这对CPU时间是极大的浪费,其中目前JAVA的NIO就属于同步非阻塞IO。

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。

从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。

一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

3 I/O多路复用模型

设想一下,我们想要得到高性能的IO模型,在阻塞I/O模式下,虽然不会占用大量的CPU时间,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。

那么我们再考虑一下非阻塞忙轮询的I/O方式,我们发现我们似乎可以利用非阻塞式I/O来达到处理多个流的目的。我们只要不停的让每个流从头到尾轮流去向内核问一遍,一直循环周而复始。这样就可以处理多个流了。

但这样的做法有个问题:如果所有的流都没有数据,那么只会白白浪费CPU。

为了避免CPU空转,可以引进了一个非阻塞式的代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,select/poll会把应用线程阻塞掉,然后自己会不断的非阻塞式的轮训注册的流,当有一个或多个流有I/O事件时,select/poll就会返回,应用线程就会从阻塞态中醒来,于是我们的程序就会知道有I/O事件完备了,至于是哪个流完备了,还要应用程序轮询一遍所有的流。

然而,如果没有I/O事件产生,我们的程序就会阻塞在select或者poll处,这依然有个问题,我们从select那里仅仅知道了有I/O事件发生了,但却并不知道是哪几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,再对他们进行操作。

即如果用select/poll,我们有O(n)的无差别轮询的时间复杂度,同时处理的流越多,每一次无差别轮询时间就越长。

为了解决这个问题,我们引入了epoll代理,epoll可以理解为event poll,不同于无差别轮询,epoll只会把哪个流发生了怎样的I/O事件通知我们。

IO multiplexing就是我们说的select,poll,epoll。IO多路复用的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。实际上select/epoll的优势并不是对于单个连接能处理得更快。

使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

IO多路复用的详细内容,可见本站博客《【I/O设计总结二】详解IO多路复用和其三种模式——select/poll/epoll》

IO多路复用是最常使用的IO模型,但是其异步程度还不够“彻底”,因为应用进程调用select/poll函数的时候,还是会被阻塞。因此IO多路复用只能称为异步阻塞IO,而非真正的异步IO。

不过Reactor设计模式优化了select/poll的阻塞性,使得IO多路复用模型变成真正的异步IO。Reactor设计模式将在《【I/O设计总结三】详解Reactor/Proactor高性能IO处理模式》中介绍。

4 信号驱动I/O模型

我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。

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

优势:等待数据报到达期间进程不被阻塞。应用进程可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

5 异步I/O模型(asynchronous IO)

linux下的asynchronous IO其实用得很少。“真正”的异步IO需要操作系统更强的支持。在IO多路复用模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在IO完成后通知用户线程直接使用即可。

异步IO模型使用了Proactor设计模式实现了上述的这一机制。Proactor设计模式将在《【I/O设计总结三】详解Reactor/Proactor高性能IO处理模式》中介绍。

6 总结

下面一图总结上述五种I/O模型:

0%