概述
一般情况下,单个进程上每次只在一个文件描述符上执行IO操作,每次 I/O 系统调用都会阻塞直到完成数据传输。比如:
- 当从一个管道中读取数据时,如果管道中恰好没有数据,那么通常 read()会阻塞。
- 如果管道中没有足够的空间保存待写入的数据时,write()将会阻塞。
当其他类型的文件比如FIFO和套接字上出现IO操作时,也会出现相似的行为
磁盘文件是个特例。内核采用缓冲区cache来加速磁盘IO请求。
- 因而一旦请求的数据传输到内核的缓冲区cache,对磁盘的write()操作将会立即返回,而不用等到将数据实际写入磁盘后才返回(除非在打开文件时指定了 O_SYNC 标志)。
- 与之对应的是,read调用将数据从内核缓冲区cache移动到用户的缓冲区中,如果请求的数据不再内核缓冲区cache,那么内核就会让进行休眠,同时执行对磁盘的读操作。
对于许多应用来说,传统的阻塞式 I/O 模型已经足够了,但这不代表所有的应用都能得到满足。但是有些应用需要处理下面一个或者所有任务。
- 如果可能的话,以非阻塞的方式检查文件描述符上是否可进行 I/O 操作。
- 同时检查多个文件描述符,看它们中的任何一个是否可以执行 I/O 操作。
我们可以使用【非阻塞式 I/O 和多进程或多线程技】部分满足这些需求:
- 如果在打开文件时设定了O_NONBLOCK标志,那么会以非阻塞方式打开文件。如果IO系统调用不能立即完成,则会返回错误而不是阻塞进程。
- 非阻塞IO可以运用到管道、FIFO、套接字、终端、伪终端以及其他一些设备上。
- 非阻塞IO可以让我们周期性的查询某个文件描述符上是否可以执行IO操作。
- 比如,我们可以让一个输入文件描述符成为非阻塞的,然后周期性的执行非阻塞式的读操作。如果我们需要同时检测多个文件描述符,那么就需要将它们都设为非阻塞,然后依次对它们进行轮询。
- 但是,这种轮询通常是我们不希望看到的。如果轮询的频率不高,那么应用程序响应IO事件的延时可能会达到无法接受的程序。
- 换句话说,在一个紧凑的循环中做轮询就是在浪费 CPU。
- 如果不希望进程在对文件描述符执行IO操作时被阻塞,我们可以创建一个新进程来执行IO。此时父进程就可以去处理其他任务了,而子进程将阻塞直到IO操作完成。如果我们需要处理单个文件描述符上的IO,此时可以为每个文件描述符创建一个子进程。这种方法的问题在于开销昂贵而且复杂。创建和维护进程对系统来说都有开销,而且一般来说子进程需要使用某种IPC机制来通知父进程有关IO操作的状态。
- 使用多线程而不是多进程,这将占用较少的资源。但是线程之间仍然需要通信,以告知其他线程有关IO操作的状态,这使得编程工作变得复杂。尤其是如果我们使用线程池技术来最小化需要处理大量并发客户的线程数量时
由于非阻塞式 I/O 和多进(线)程都有各自的局限性,下列备选方案往往更可取。
- IO多路复用允许进程同时检测多个文件描述符以找到它们中的任何一个是否可执行IO操作。系统调用select()和poll()用来执行IO多路复用
- 信号驱动IO是指当有输入或者数据可以写到指定文件描述符上时,内核向请求数据的进程发送一个信号。进程可以处理其他的任务,当IO操作可执行时通过接收信号来获得通知。当同时检查大量的文件描述符时,信号驱动IO比select和poll由显著的性能提升
- epoll API是Linux特有的性能,首次出现是在 Linux 2.6 版中。同IO多路复用API一样,epoll API运行进程同时检查多个文件描述符,看其中任意一个是否能够执行IO操作。同信号驱动IO一样,当同时检查大量文件描述符时,epoll能提供更好的性能
这些技术也可以应用到多线程应用中。
实际上IO多路复用、信号驱动IO以及epoll都是用来实现同一个目标的技术-----同时检查多个文件描述符,看他们是否准备好了执行IO操作(准确的说,是看IO系统调用是否可以非阻塞的执行)。文件描述符就绪状态的转换是通过一些IO事件来触发的,比如输入数据到达,套接字连接建立完成,或者是之前满载的套接字发送缓冲区在TCP将队列中的数据传送到对端之后有了剩余空间。同时检查多个文件描述符在类似网络服务器的应用中很有好处,或者是那些必须同时检查终端以及管道或套接字输入的应用程序
需要注意的是这些技术都不会执行实际的IO操作,它们只是告诉我们某个文件描述符已经处于就绪状态了。这时需要调用其他的系统调用来完成IO操作
优缺点
- 系统调用select和poll在Unix系统中已经存在很长时间了。同其他技术相比,它们的主要优势在于可移植性。主要缺点是当同时检查大量的文件描述符时性能延展性不佳
- epoll API的关键优势是它能让应用程序高效的检查大量的文件描述符。主要缺点是它是专属于Linux系统的API
- 同epoll一样,信号驱动IO也可以让应用程序高效的检查大量的文件描述符。但epoll有一些信号驱动IO没有的优点:
- 避免了处理信号的复杂性
- 我们可以指定想要检查的事件类型(即,读就绪或者写就绪)。
- 我们可以选择以水平触发或边缘触发的形式来通知进程
另外,要完全利用信号 I/O 的优点需要用到不可移植的 Linux 专有的特性,而如果我们这么做了,那么信号驱动 I/O 的可移植性也不会比 epoll 更好。
Libevent 库是这样一个软件层,它提供了检查文件描述符 I/O 事件的抽象,已经移植到了多个 UNIX 系统中。Libevent 的底层机制能够(以透明的方式)应用上面各种技术:select()、poll()、信号驱动 I/O 或者 epoll。同样,也支持 Solaris 专有的/dev/poll 接口和 BSD系统的 kqueue 接口。(因此,libevent 也可以作为如何使用这些技术的绝佳示例。)libevent 的作者是 Niels Provos,该项目可在 http://monkey.org/~provos/ libevent/上找到
水平触发和边缘触发
有两种文件描述符准备就绪的通知模式
- 水平触发通知:如果文件描述符上可以非阻塞的执行IO系统描述符,此时认为它已经就绪
- 边缘触发通知:如果文件描述符自上次状态检查以来有了新的IO活动(比如新的输入),此时就需要触发通知。
下表总结了 I/O 多路复用、信号驱动 I/O 以及 epoll 所采用的通知模型。epoll API 同其他两种 I/O 模型的区别在于它对水平触发(默认)和边缘触发都支持。
当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态。这表示当我们确定了文件描述符处于就绪态时(比如由输入数据),就可以对其执行一些IO操作,然后重复检查文件描述符,看看是否仍然出于就绪状态态(比如还有更多的输入数据),此时我们就能执行更多的 I/O,以此类准。换句话说,由于水平触发模式允许我们在任意时刻重复检查IO状态,没有必要每次当文件描述符就绪后需要尽可能多的执行IO(也就是尽可能多的读取字节,亦或是根本不去执行任何 I/O)
与之相反的是,当我们采用边缘触发时,只有当IO事件发生时我们才收到通知。在另一个IO事件到来前我们不会收到任何新的通知。另外,当文件描述符收到IO事件通知时,通常我们并不知道要处理多少IO(例如有多少字节可读)。因此,采用边缘触发通知的程序通常要按照如下规则设计:
- 在接受到一个IO事件时,程序在某个时刻应该在相应的文件描述符上尽可能多的执行IO(比如尽可能多地读取字节)。如果程序没有这么做,那么久可能失去执行IO的机会。因为直到产生另一个IO事件为止,在此之前都不会再接收到通知了,因此也就不知道此时应该执行IO操作。这将导致数据丢失或者程序中出现阻塞。前面我们说“在某个时刻”,是因为有时候当我们确定了文件描述符是就绪态时,此时可能并不适合马上执行所有的 I/O 操作。问题的原因在于如果我们仅对一个文件描述符执行大量的 I/O 操作,可能会让其他文件描述符处于饥饿状态。
- 如果程序采用循环来对文件描述符执行尽可能多的IO,而文件描述符又被置为可阻塞的,那么最终当没有更多的IO执行时,IO系统调用就会被阻塞。基于这个原因,每个被检测的文件描述符通常应该置为非阻塞模式,在得到IO事件通知后重复执行IO操作,直到相应的系统调用(比如 read(),write())以错误码 EAGAIN 或EWOULDBLOCK 的形式失败。
总结:在水平触发通知模型下,只要当前文件描述符上可以进行 I/O 操作,我们就能得到通知。与之相反,在边缘触发通知模型下,只有自上一次监视以来,文件描述符上有发生 I/O 事件时才会通知我们。I/O 多路复用采用的是水平触发通知模型;信号驱动 I/O 基本上是边缘触发通知模型;而 epoll 能够以任意一种方式工作(默认情况下是水平触发)。边缘触发通知通常都和非阻塞式 I/O 结合起来使用