TLPI读书笔记第63章-备选IO模型1

63.1 整体概览

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

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

1.如果可能的话,以非阻塞的方式检查文件描述符上是否可进行 I/O 操作。

2.同时检查多个文件描述符,看它们中的任何一个是否可以执行 I/O 操作。

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

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

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

如果不希望进程在对文件描述符执行 I/O 操作时被阻塞,我们可以创建一个新的进程来执行 I/O。此时父进程就可以去处理其他的任务了,而子进程将阻塞直到 I/O 操作完成。如果我们需要处理多个文件描述符上的 I/O,此时可以为每个文件描述符创建一个子进程。这种方法的问题在于开销昂贵且复杂。创建及维护进程对系统来说都有开销,而且一般来说子进程需要使用某种IPC机制来通知父进程有关 I/O 操作的状态。 使用多线程而不是多进程,这将占用较少的资源。但线程之间仍然需要通信,以告知其他线程有关 I/O 操作的状态,这将使编程工作变得复杂。尤其是如果我们使用线程池技术来最小化需要处理大量并发客户的线程数量时。(多线程特别有用的一个地方是如果应用程序需要调用一个会执行阻塞式 I/O 操作的第三方库, 那么可以通过在分离的线程中调用这个库从而避免应用被阻塞。 )

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

1.I/O 多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否可执行I/O 操作。系统调用 select()和 poll()用来执行 I/O 多路复用。

2.信号驱动 I/O 是指当有输入或者数据可以写到指定的文件描述符上时, 内核向请求数据的进程发送一个信号。进程可以处理其他的任务,当 I/O 操作可执行时通过接收信号来获得通知。当同时检查大量的文件描述符时,信号驱动 I/O 相比 select()和 poll()有显著的性能提升。

3.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 操作。

选择哪种技术

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

1.系统调用 select()和 poll()在 UNIX 系统中已经存在了很长的时间。同其他技术相比,它们主要的优势在于可移植性,主要缺点在于当同时检查大量的(数百或数千个)文件描述符时性能延展性不佳。

2.epoll API 的关键优势在于它能让应用程序高效地检查大量的文件描述符。其主要缺点在于它是专属于 Linux 系统的 API。

3.同 epoll 一样,信号驱动 I/O 可以让应用程序高效地检查大量的文件描述符。 但是 epoll有一些信号驱动 I/O 所没有的优点。

3.1.避免了处理信号的复杂性。 3.2.我们可以指定想要检查的事件类型(读就绪或者写就绪)。 3.3.我们可以选择以水平触发或边缘触发的形式来通知进程 另外,要完全利用信号 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 接口。

63.1.1 水平触发和边缘触发

在深入讨论多种可选的 I/O 机制之前, 我们需要先区分两种文件描述符准备就绪的通知模式。

1.水平触发通知:如果文件描述符上可以非阻塞地执行 I/O 系统调用,此时认为它已经就绪。

2.边缘触发通知:如果文件描述符自上次状态检查以来有了新的 I/O 活动(比如新的输入),此时需要触发通知。 表 63-1 总结了 I/O 多路复用、信号驱动 I/O 以及 epoll 所采用的通知模型。 epoll API 同其他两种 I/O 模型的区别在于它对水平触发(默认)和边缘触发都支持。

select、poll:水平触发

信号驱动:边缘触发

epoll:水平触发和边缘触发

有关这两种通知模型区别的细节将在本章的学习中逐渐清晰。现在我们讨论一下通知模型的选择是如何影响我们设计程序的方式的。 当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态。这表示当我们确定了文件描述符处于就绪态时(比如存在有输入数据),就可以对其执行一些 I/O 操作, 然后重复检查文件描述符,看看是否仍然处于就绪态(比如还有更多的输入数据),此时我们就能执行更多的I/O,以此类准。换句话说,由于水平触发模式允许我们在任意时刻重复检查 I/O 状态,没有必要每次当文件描述符就绪后需要尽可能多地执行 I/O(也就是尽可能多地读取字节,亦或是根本不去执行任何 I/O)。

与之相反的是,当我们采用边缘触发时,只有当 I/O 事件发生时我们才会收到通知。在另一个 I/O 事件到来前我们不会收到任何新的通知。另外,当文件描述符收到 I/O 事件通知时,通常我们并不知道要处理多少 I/O(例如有多少字节可读)。因此,采用边缘触发通知的程序通常要按照如下规则来设计。

1.在接收到一个 I/O 事件通知后,程序在某个时刻应该在相应的文件描述符上尽可能多地执行 I/O(比如尽可能多地读取字节)。 如果程序没这么做, 那么就可能失去执行 I/O的机会。因为直到产生另一个 I/O 事件为止,在此之前程序都不会再接收到通知了,因此也就不知道此时应该执行 I/O 操作。这将导致数据丢失或者程序中出现阻塞。前面我们说“在某个时刻”,是因为有时候当我们确定了文件描述符是就绪态时,此时可能并不适合马上执行所有的 I/O 操作。问题的原因在于如果我们仅对一个文件描述符执行大量的 I/O 操作,可能会让其他文件描述符处于饥饿状态。在 63.4.6 节中,我们对 epoll API 的边缘触发通知做介绍时再深入讨论这个问题。

2.如果程序采用循环来对文件描述符执行尽可能多的 I/O,而文件描述符又被置为可阻塞的,那么最终当没有更多的 I/O 可执行时, I/O 系统调用就会阻塞。基于这个原因,每个被检查的文件描述符通常都应该置为非阻塞模式,在得到 I/O 事件通知后重复执行I/O 操作,直到相应的系统调用(比如 read() , write() )以错误码 EAGAIN 或 EWOULDBLOCK 的形式失败。

63.1.2 在备选的 I/O 模型中采用非阻塞 I/O

非阻塞 I/O( O_NONBLOCK 标志)常和本章中所描述的 I/O 模型一起使用。下面列出了一些例子,以说明为什么这么做会很有用。

1.如同上一节所述,非阻塞 I/O 通常和提供有边缘触发通知机制的 I/O 模型一起使用。

2.如果多个进程(或线程)在同一个打开的文件描述符上执行 I/O 操作,那么从某个特定进程的角度来看,文件描述符的就绪状态可能会在通知就绪和执行后续I/O调用之间发生改变。结果就是一个阻塞式的 I/O 调用将阻塞,从而防止进程检查其他的文件描述符。(这种情况会发生在本章所描述的所有 I/O 模型上,无论它们采用的是水平触 发还是边缘触发。 )

3.尽管水平触发模式的API 比如select()或poll()通知我们流式套接字的文件描述符已经写就绪了,如果我们在单个 write()或 send()调用中写入足够大块的数据,那么该调用将阻塞。

4.在非常罕见的情况下,水平触发型的 API 比如 select()和 poll(),会返回虚假的就绪通知—它们会错误地通知我们文件描述符已经就绪了。这可能是由内核 bug 造成的,或非普通情况下的设计方案所期望的行为。

 

63.6 总结

本章我们探究了针对标准 I/O 模型之外的其他几种可选的 I/O 模型。它们是: I/O 多路复用( select()和 poll())、信号驱动 I/O 以及 Linux 专有的 epoll API。所有这些机制都允许我们监视多个文件描述符,以查看哪个文件描述符上可执行 I/O 操作。需要注意的是,所有这些机制并不实际执行 I/O 操作。相反,一旦发现某个文件描述符处于就绪态,我们仍然采用传统的I/O 系统调用来完成实际的 I/O 操作。

I/O 多路复用机制中的 select()和 poll()能够同时监视多个文件描述符,以查看哪个文件描述符上可执行 I/O 操作。在这两个系统调用中,我们传递一个待监视的文件描述符列表给内核,之后内核返回一个修改过的列表以表明哪些文件描述符处于就绪态了。在每一次调用中都要传递完整的文件描述符列表,并且在调用返回后还要检查它们,这个事实表明当需要监视大量的文件描述符时, select()和 poll()的性能表现将变得很差。

信号驱动 I/O 允许一个进程在文件描述符处于 I/O 就绪态时接收到一个信号。 要使用信号驱动 I/O,我们必须为 SIGIO 信号安装一个信号处理例程,设定接收信号的属主进程,并在打开文件时设定 O_ASYNC 标志使得信号可以生成。相比 I/O 多路复用,当监视大量的文件描述符时信号驱动 I/O 有着显著的性能优势。 Linux 允许我们修改用来通知的信号,而如果我们采用实时信号的话,那么多个信号通知就可以排队处理。信号处理例程可以使用 siginfo_t 参数来确定产生信号的文件描述符以及发生事件的类型。

同信号驱动 I/O 一样,当监视大量的文件描述符时 epoll 也能提供高效的性能。 epoll(以及信号驱动 I/O)的性能优势源自内核能够“记住”进程正在监视的文件描述符列表这一事实(与之相反的是, select()和 poll()都必须反复告诉内核哪些文件描述符需要监视)。相比于信号驱动 I/O, epoll API 还有些值得一提的优点:我们可以避免处理信号时的复杂流程,而且可以指定需要监视的 I/O 事件类型(例如输入或输出事件)

本章中我们在水平触发通知和边缘触发通知之间做了严格区分。在水平触发通知模型下,只要当前文件描述符上可以进行 I/O 操作,我们就能得到通知。与之相反,在边缘触发通知模型下,只有自上一次监视以来,文件描述符上有发生 I/O 事件时才会通知我们。

I/O 多路复用采用的是水平触发通知模型;信号驱动 I/O 基本上是边缘触发通知模型;而 epoll 能够以任意一种方式工作(默认情况下是水平触发)。边缘触发通知通常都和非阻塞式 I/O 结合起来使用。

本章结尾部分我们探讨了一个经常会遇到的问题。那就是如何在监视多个文件描述符的同时等待信号的发送?对于这个问题,通常的解决方案是采用一种称为 self-pipe 的技巧,即信号处理例程写一个字节数据到管道中,代表管道读端的文件描述符包含在被监视的文件描述符集合中。 SUSv3中定义了 pselect(),这是 select()的变种,它提供了解决这个问题的另一种方法。但是 pselect()并没有包含在所有的 UNIX 实现中。 Linux 也提供了类似(但非标准)的 ppoll()和 epoll_pwait()接口。

原文地址:https://www.cnblogs.com/wangbin2188/p/14819397.html