当前位置:嗨网首页>书籍在线阅读

02-整体概览

  
选择背景色: 黄橙 洋红 淡粉 水蓝 草绿 白色 选择字体: 宋体 黑体 微软雅黑 楷体 选择字体大小: 恢复默认

63.1 整体概览

目前为止,本书中大部分程序使用的I/O模型都是单个进程每次只在一个文件描述符上执行I/O操作,每次I/O系统调用都会阻塞直到完成数据传输。比如,当从一个管道中读取数据时,如果管道中恰好没有数据,那么通常read()会阻塞。而如果管道中没有足够的空间保存待写入的数据时,write()也会阻塞。当在其他类型的文件如FIFO和套接字上执行I/O操作时,也会出现相似的行为。

磁盘文件是个特例。如第13章中所描述的,内核采用缓冲区cache来加速磁盘I/O请求。因而一旦请求的数据传输到内核的缓冲区cache,对磁盘的write()操作将立刻返回,而不用等到将数据实际写入磁盘后才返回(除非在打开文件时指定了O_SYNC标志)。与之对应的是,read()调用将数据从内核缓冲区 cache 移动到用户的缓冲区中,如果请求的数据不在内核缓冲区 cache,那么内核就会让进程休眠,同时执行对磁盘的读操作。

对于许多应用来说,传统的阻塞式I/O模型已经足够了,但这不代表所有的应用都能得到满足。特别的,有些应用需要处理以下某项任务,或者两者都需要兼顾。

  • 如果可能的话,以非阻塞的方式检查文件描述符上是否可进行I/O操作。
  • 同时检查多个文件描述符,看它们中的任何一个是否可以执行I/O操作。

我们已经遇到了两种可以部分满足这些需求的技术:非阻塞式I/O和多进程或多线程技术。

我们在5.9节和44.9节中对非阻塞式I/O做了详细的说明。如果在打开文件时设定了O_NONBLOCK标志,会以非阻塞方式打开文件。如果I/O系统调用不能立刻完成,则会返回错误而不是阻塞进程。非阻塞式I/O可以运用到管道、FIFO、套接字、终端、伪终端以及其他一些类型的设备上。

非阻塞式I/O可以让我们周期性地检查(“轮询”)某个文件描述符上是否可执行I/O操作。比如,我们可以让一个输入文件描述符成为非阻塞式的,然后周期性地执行非阻塞式的读操作。如果我们需要同时检查多个文件描述符,那么就需要将它们都设为非阻塞,然后依次对它们轮询。但是,这种轮询通常是我们不希望看到的。如果轮询的频率不高,那么应用程序响应I/O事件的延时可能会达到无法接受的程度。换句话说,在一个紧凑的循环中做轮询就是在浪费CPU。

本章中我们以两种截然不同的方式来使用轮询(poll)这个词。其中一种代表I/O多路复用的系统调用poll()。另一种则表示“以非阻塞的方式检查文件描述符的状态”。

如果不希望进程在对文件描述符执行 I/O 操作时被阻塞,我们可以创建一个新的进程来执行 I/O。此时父进程就可以去处理其他的任务了,而子进程将阻塞直到 I/O操作完成。如果我们需要处理多个文件描述符上的 I/O,此时可以为每个文件描述符创建一个子进程。这种方法的问题在于开销昂贵且复杂。创建及维护进程对系统来说都有开销,而且一般来说子进程需要使用某种 IPC 机制来通知父进程有关 I/O 操作的状态。

使用多线程而不是多进程,这将占用较少的资源。但线程之间仍然需要通信,以告知其他线程有关I/O操作的状态,这将使编程工作变得复杂。尤其是如果我们使用线程池技术来最小化需要处理大量并发客户的线程数量时。(多线程特别有用的一个地方是如果应用程序需要调用一个会执行阻塞式 I/O 操作的第三方库,那么可以通过在分离的线程中调用这个库从而避免应用被阻塞。)

由于非阻塞式I/O和多进(线)程都有各自的局限性,下列备选方案往往更可取。

  • I/O多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否可执行I/O操作。系统调用select()和poll()用来执行I/O多路复用。
  • 信号驱动I/O是指当有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号。进程可以处理其他的任务,当I/O操作可执行时通过接收信号来获得通知。当同时检查大量的文件描述符时,信号驱动I/O相比select()和poll()有显著的性能提升。
  • epoll API是Linux专有的特性,首次出现是在Linux 2.6版中。同I/O多路复用API一样,epoll API允许进程同时检查多个文件描述符,看其中任意一个是否能执行I/O操作。同信号驱动I/O一样,当同时检查大量文件描述符时,epoll能提供更好的性能。

本章余下的部分我们将主要对上述技术进行讨论。但是,这些技术也可以应用到多线程应用中。

实际上 I/O 多路复用、信号驱动 I/O以及epoll都是用来实现同一个目标的技术——同时检查多个文件描述符,看它们是否准备好了执行 I/O 操作(准确地说,是看 I/O系统调用是否可以非阻塞地执行)。文件描述符就绪状态的转化是通过一些I/O事件来触发的,比如输入数据到达,套接字连接建立完成,或者是之前满载的套接字发送缓冲区在TCP将队列中的数据传送到对端之后有了剩余空间。同时检查多个文件描述符在类似网络服务器的应用中很有用处,或者是那些必须同时检查终端以及管道或套接字输入的应用程序。

需要注意的是这些技术都不会执行实际的I/O操作。它们只是告诉我们某个文件描述符已经处于就绪状态了。这时需要调用其他的系统调用来完成实际的I/O操作。

本章我们没有介绍的一种I/O模型是POSIX异步I/O(AIO)。POSIX AIO允许进程将I/O操作排列到一个文件中,当操作完成后得到通知。POSIX AIO的优点在于最初的I/O调用将立刻返回,因此进程不会一直等待数据传输到内核或者等待操作完成。这使得进程可以同I/O操作一起并行处理其他的任务(可能会包含将未来的I/O操作入队列)。对于特定类型的应用,POSIX AIO能提供有用的性能优势。目前,Linux在glibc中提供有基于线程的POSIX AIO实现。写作本书时,人们正在朝着内核化的POSIX AIO实现而努力,这应该能提供更好的伸缩性能。POSIX AIO 的描述可在[Gallmeister, 1995]和[Robbins & Robbins, 2003]中找到。

选择哪种技术

在本章中,我们将思考为何要选择其中的某种技术,为什么其他技术不适用,其理由是什么。同时我们会总结出一些要点。

  • 系统调用select()和poll()在UNIX系统中已经存在了很长的时间。同其他技术相比,它们主要的优势在于可移植性,主要缺点在于当同时检查大量的(数百或数千个)文件描述符时性能延展性不佳。
  • epoll API的关键优势在于它能让应用程序高效地检查大量的文件描述符。其主要缺点在于它是专属于Linux系统的API。

一些其他的UNIX实现提供了(非标准的)类似于epoll的机制。比如,Solaris提供了特殊的/dev/poll文件(在Solaris poll(7d)手册页中描述),而其他一些BSD变种提供了kqueue API(相比epoll,这是一种更为通用的检查机制)。[Stevens et al,. 2004]中简要介绍了这两种机制。关于kqueue的更多讨论可以在[Lemon, 2001]中找到。

  • 同epoll一样,信号驱动I/O可以让应用程序高效地检查大量的文件描述符。但是epoll有一些信号驱动I/O所没有的优点。
    • 避免了处理信号的复杂性。
    • 我们可以指定想要检查的事件类型(即,读就绪或者写就绪)。
    • 我们可以选择以水平触发或边缘触发的形式来通知进程(在63.1.1节中详述)。

另外,要完全利用信号 I/O 的优点需要用到不可移植的 Linux 专有的特性,而如果我们这么做了,那么信号驱动 I/O 的可移植性也不会比epoll更好。

因为从另一方面来说select()和poll()的可移植性更好,而信号驱动I/O和epoll有着更好的性能表现。对于某些应用来说,编写一个软件抽象层来检查文件描述符事件是非常值得做的。有了这样一个抽象层,可移植的程序就能在提供有epoll机制的系统上应用epoll(或类似的API),而在其他系统上继续使用select()和poll()。

Libevent库就是这样一个软件层,它提供了检查文件描述符 I/O 事件的抽象,已经移植到了多个UNIX系统中。Libevent的底层机制能够(以透明的方式)应用本章所描述的任意一种技术:select()、poll()、信号驱动I/O或者epoll。同样,也支持Solaris专有的/dev/poll接口和BSD系统的kqueue接口。(因此,libevent也可以作为如何使用这些技术的绝佳示例。)libevent的作者是Niels Provos,该项目可在http://monkey.org/~provos/libevent/上找到。