常见的I/O模型

服务器所使用的架构往往要和I/O模型相结合,才能应用到某种具体的场景上。处理客户端发送过来的请求,可以采用多进程或多线程。对于纯粹的多进程或多线程,接收到一个连接就开启一个进程或线程,阻塞直到有读写发生。在高并发的情况下,不断地开启进程和占用大量内存,还有线程上下文切换等都会对系统资源造成极大的浪费。为了减少系统的开销,非常有必要采取手段降低并发的进程或线程的数量,如I/O多路复用(I/O multiplexing)就很好地解决了这个问题,通过这种技术一个线程就可以管理多个I/O数据流的读写操作。

传统的I/O模型

阻塞I/O

在阻塞I/O中,速度比较慢的 I/O 操作可能引起不可预测的结果。一般情况下,系统调用执行I/O操作不能立即完成时,会直接进入睡眠状态。比如,当读取数据时,如果缓冲区中无数据或数据少于系统调用请求数据量的大小,线程就会阻塞住,直到向缓冲区写入了足够多的数据可供线程读取时,产生一个中断把正在睡眠的线程唤醒,开始读取数据;当写入数据时,如果缓冲区没有足够的空间或者空间小于待写入的数据量所要求的大小时,线程也会阻塞住,直到缓冲区有了足够大的空间可供线程写入时,也会产生一个中断把正在睡眠的线程唤醒,开始写入数据。当线程完成数据的读取或写入操作后,其执行的系统调用才会返回。调用过程如图1所示。

img

图1 阻塞I/O

非阻塞I/O

使用非阻塞型I/O时,应用程序可以在执行一个比较慢速的I/O操作时,立即就可以得到一个结果。如果得到的结果是I/O操作还没有完成,再次执行系统调用去查看I/O操作的结果。非阻塞 I/O 的问题也在于此,尽管应用程序可以在执行一个慢速的I/O 操作立即返回,但是却无法知道什么时候I/O操作才能完成。这就需要应用程序不得不每过一段时间轮询I/O操作是否完成,严重地浪费了CPU 资源。当操作系统内核的缓冲区没有可读数据时,系统调用recvfrom()执行后会立即返回一个错误,然后重复执行,直到缓冲区有可读数据,就会把这些数据从内核复制到用户空间中。但是每次轮询的时间长短是无法事先知道的,时间过短的话,recvfrom()执行太过频繁;过长的话,影响服务器的吞吐性能。调用过程如图2所示。

img

图2 非阻塞I/O

异步I/O

在异步I/O中,由操作系统负责数据具体的读写操作,应用程序不需要处理数据,也不需要时刻去轮询读写操作是否完成。应用程序只需要将文件描述符、缓冲区的地址等一些I/O需要的数据传递给内核,就可以去处理其它的事件。操作系统会负责具体数据的I/O,缓冲区之间的复制操作也不再需要应用程序关心。当数据的I/O完成时,应用程序接收到操作系统发给应用程序I/O完成的通知,接下来可以再决定后续的操作。Linux操作系统在2.6版本时才正式引入了异步I/O,虽然经过了几年的发展但是还是不够成熟。调用过程如图3所示。

img

图3 异步I/O

信号驱动I/O

在信号驱动I/O中,当指定的文件描述符上有事件发生时,操作系统通过信号的方式把通知传递给该应用程序,弥补了传统的非阻塞I/O使用轮询造成的额外的系统开销的不足[17]。当应用程序开始执行一个I/O操作时,先执行读或写的系统调用告知操作系统的内核启动具体的I/O操作,I/O操作启动后会马上返回。当I/O操作在执行完后或者在执行过程中发生错误时,操作系统内核会按照应用程序进程事先约定好的某种方式通知进程。

但是,信号驱动I/O的一些设计上存在缺陷,导致它并不适于服务器应用程序的网络通信中。首先,信号是不排队的。一个进程中如果已经有一个信号产生了并被挂起时。此时,如果同一个信号再次发生,就会被忽略。这样并不能保证进程中每一个事件触发的信号都会被处理。而且,应用程序无法通过信号驱动I/O得知有多少事件发生、发生了什么事件,所以需要对所有的文件描述符进行检查。调用过程如图4所示。

img

图4 信号驱动I/O

I/O多路复用

在I/O多路复用中,主线程可以通过监听多个描述符等待I/O事件的就绪,如数据可读或可写,这其实是通过阻塞I/O实现的。调用过程如图4所示。

img

图5 I/O多路复用

在I/O多路复用中,应用程序事先会告诉操作系统需要监听哪一个事件,然后事件就绪后由操作系统通知应用程序。使用I/O利用技术后服务器可以监听多个端口,能同时处理多个连接,使服务器处理高并发请求的能力大为提高。虽然通过这种方式可以对多个文件描述符进行监听,但是它本身还是通过阻塞I/O实现的。当I/O操作准备就绪后,还需要结合多线程技术实现真正意义上的并发。在Linux类操作系统中,提供的I/O多路复用方案主要有select、poll和epoll有三种,分别提供了三个系统调用。

  • select。系统调用select同时处理的文件描述符数量存在最大值的限制,而且在轮询地检测I/O操作是否就绪时,需要在内核的缓冲区和用户空间之间复制文件描述符集。

  • poll。系统调用poll只需要传递一个文件描述符集的参数,也是采用轮询的试去检测I/O事件是否就绪。

  • epoll。epoll是对前两种方案的改进,除了ET工作模式,它还有另外一种工作模式LT(Level Trigger),默认的工作模式为LT。在LT工作模式下,epoll比较类似于poll,也是采用轮询的方式去检测I/O事件是否就绪。在LT模式下,epoll可以支持阻塞I/O和非阻塞I/O。当内核通知应用程序后,若应用程序没有采取任何处理,依然会不断地发送通知给应用程序。在另外一种工作模式ET下,epoll通过一个事件表来管理文件描述符集,只有当事件的状态发生变化时,应用程序才会收到通知。epoll是针对前两种方案的不足而设计的,引入eventpollfs后以共享内存的方式传递参数,在以后的轮询时减少了数据复制的次数。

网络I/O模型

在网络编程中,有两种事件:一种是I/O事件,即数据的读写操作;另一种是非I/O事件,如编码、解码等业务相关的复杂的计算操作。所以有了事件处理这一网络设计模式,并且有两种高效的事件处理模式Reactor和Proactor专门用于处理网络I/O。

Reactor模型

在Reactor中,事件被关注后,将被同步地从事件源进行分离和分配[19]。I/O事件只有当I/O操作准备好之后才可以处理,而非I/O事件可以单独地去处理。应用程序只会关心监听的文件描述符上事件的状态有没有改变,当事件状态发生变化后,它会马上对这个事件进行处理。它不会做任何有关业务的工作,对于数据的读写和编码解码等工作均安排给其它线程完成。

在Linux操作系统设计的Reactor模式,一般的工作流程如图5所示。

img

图6 Reactor模型
  • 应用程序的主线程注册读就绪事件并通过epoll_wait()关注,之后不断地查看是否有数据可读;
  • 当可以读取数据时,操作系统会通知应用程序,由应用程序的主线程将此事件放到任务队列中去;
  • 然后,某个从睡眠状态下被唤醒的线程从队列头部把最先放进去的事件取出来,开始处理读事件,并向操作系统注册写事件;
  • 接着,应用程序的主线程再次调用epoll_wait(),后续的处理程序与读事件的处理类似。

相比传统的网络模型,Reactor不再为每一个客户端的请求连接分配一个线程,通过I/O多路复用技术,使已经建立好的线程达到复用的目的,数据的I/O操作完成后,再选择线程池中的工作线程来处理非I/O事件。在这种模式中,节省了等待I/O操作完成就绪的时间,提高了多线程的利用率。通过线程池技术,也减少了使用多线程带来的上下文切换等造成的系统开销。

Proactor模型

在Proactor中,工作线程只需要专注于业务逻辑的处理。当一个I/O操作发起时,操作系统将来自于网络中的I/O数据由内核的缓冲区复制到用户空间,当事件处理器监听到I/O操作读写完成的消息后,通知工作线程来处理数据读取后的工作,所有的I/O操作都是交给操作系统来完成的[20]。
在Linux操作系统设计的Proactor一般的工作流程如图6所示。

img

图7 Proactor模型
  • 应用程序线程通过aio_read()向操作系统注册读完成事件,然后,返回继续执行其它操作。
  • 当数据读写完成后,操作系统会传递一个信号给应用程序,告诉事件读写已经完成,由应用程序的主线程完成善后工作。
  • 主线程会选择一个工作线程来处理来自客户端请求的数据。完成处理后,再通过aio_write()注册一个写完成事件,之后,继续执行其它操作。
  • 当数据写入后事件完成,操作系统发送一个信号通知应用程序,由其选择一个工作线程来完成最后的工作。

Reactor和Proactor都有各自的应用场景。在Reactor中需要工作线程完成I/O操作,而在Proactor中,具体的I/O是由系统完成的。但是Proactor设计更为复杂,实现起来难度大,而且也不方便错误追踪。