TLPI读书笔记第63章:IO多路复用3

63.2 I/O 多路复用

I/O 多路复用允许我们同时检查多个文件描述符,看其中任意一个是否可执行 I/O 操作。我们可以采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作。第一个是 select(),它首次出现在 BSD 系统的套接字 API 中。在这两个系统调用中,历史上 select()的应用更广泛。另一个系统调用是 poll(), 它出现在 System V 中。 select()和 poll()现在都是 SUSv3 中规定的标准接口。 我们可以在普通文件、终端、伪终端、管道、 FIFO、套接字以及一些其他类型的字符型设备上使用 select()和 poll()来检查文件描述符。 这两个系统调用都允许进程要么一直等待文件描述符成为就绪态,要么在调用中指定一个超时时间。

63.2.1 select()系统调用

系统调用 select()会一直阻塞,直到一个或多个文件描述符集合成为就绪态

#include<sys/time.h>
#include<sys/select.h>
int select(int nfds,fd_set *readfds,fd_set *writefds,fd_set *exceptfds,struct timeval *timeout)
/*返回已经就绪的文件描述符数*/

参数 nfds、 readfds、 writefds 和 exceptfds 指定了 select()要检查的文件描述符集合。参数timeout可用来设定 select()阻塞的时间上限。我们接下来详细描述这些参数的意义。

文件描述符集合

参数 readfds、 writefds 以及 exceptfds 都是指向文件描述符集合的指针,所指向的数据类型是 fd_set。这些参数按照如下方式使用。

1.readfds 是用来检测输入是否就绪的文件描述符集合。

2.writefds 是用来检测输出是否就绪的文件描述符集合。

3.exceptfds 是用来检测异常情况是否发生的文件描述符集合。

术语“异常情况”常常被误解为在文件描述符上出现了一些错误,这并不正确。在 Linux 上,一个异常情况只会在下面两种情况下发生(其他的 UNIX 实现也类似)。

1.连接到处于信包模式下的伪终端主设备上的从设备状态发生了改变(见 64.5 节)。

2.流式套接字上接收到了带外数据(见 61.13.1 节)。

通常,数据类型 fd_set 以位掩码的形式来实现。但是,我们并不需要知道这些细节,因为所有关于文件描述符集合的操作都是通过四个宏来完成的: FD_ZERO(), FD_SET(),FD_CLR()以及 FD_ISSET()

#include<sys/select.h>
void FD_ZERO(fd_set *fdset);
/*FD_ZERO()将 fdset 所指向的集合初始化为空。*/
void FD_SET(int fd,fd_set *fdset);
/*FD_SET()将文件描述符 fd 添加到由 fdset 所指向的集合中。*/
void FD_CLR(int fd,fd_set *fdset);
/*FD_CLR()将文件描述符 fd 从 fdset 所指向的集合中移除。*/
int FD_ISSET(int fd,fd_set *fdset) ;
/*如果文件描述符 fd 是 fdset 所指向的集合中的成员, FD_ISSET()返回 true。*/

文件描述符集合有一个最大容量限制,由常量 FD_SETSIZE 来决定。在 Linux 上,该常量的值为 1024。 (其他 UNIX 实现对于该限制也有类似的常量值来限定。 )

参数 readfds、 writefds 和 exceptfds 所指向的结构体都是保存结果值的地方。 在调用 select()之前,这些参数指向的结构体必须初始化(通过 FD_ZERO()和 FD_SET()),以包含我们感兴趣的文件描述符集合。之后 select()调用会修改这些结构体,当 select()返回时,它们包含的就是已处于就绪态的文件描述符集合了。(由于这些结构体会在调用中被修改,如果要在循环中重复调用 select(),我们必须保证每次都要重新初始化它们。 )之后这些结构体可以通过FD_ISSET()来检查。

如果我们对某一类型的事件不感兴趣,那么相应的 fd_set 参数可以指定为 NULL。我们将在 63.2.3 节中对这三种事件类型做更准确的解释。

参数 nfds 必须设为比 3 个文件描述符集合中所包含的最大文件描述符号还要大 1。该参数让 select()变得更有效率,因为此时内核就不用去检查大于这个值的文件描述符号是否属于这些文件描述符集合。

select()的返回值

作为函数的返回值, select()会返回如下几种情况中的一种。 返回-1 表示有错误发生。 可能的错误码包括 EBADF 和 EINTR。 EBADF 表示 readfds、writefds 或者 exceptfds 中有一个文件描述符是非法的(例如当前并没有打开)。 EINTR表示该调用被信号处理例程中断了。(如 21.5 节所述, 如果被信号处理例程中断, select()是不会自动恢复的。 ) 返回 0 表示在任何文件描述符成为就绪态之前 select()调用已经超时。在这种情况下,每个返回的文件描述符集合将被清空。 返回一个正整数表示有 1 个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符个数。在这种情况下,每个返回的文件描述符集合都需要检查(通过 FD_ISSET()),以此找出发生的 I/O 事件是什么。 如果同一个文件描述符在 readfds、writefds 和 exceptfds 中同时被指定,且它对于多个 I/O 事件都处于就绪态的话,那么就会被统计多次。换句话说, select()返回所有在 3 个集合中被标记为就绪态的文件描述符总数。

63.2.2 poll()系统调用

系统调用 poll()执行的任务同 select()很相似。 两者间主要的区别在于我们要如何指定待检查的文件描述符。在 select()中,我们提供三个集合,在每个集合中标明我们感兴趣的文件描述符。 而在 poll()中我们提供一列文件描述符, 并在每个文件描述符上标明我们感兴趣的事件。 参数 fds 列出了我们需要 poll()来检查的文件描述符。该参数为 pollfd 结构体数组,其定义如下。

#include<poll.h>
int poll(struct pollfd fds[],nfds_t nfds,int timeout);
struct pollfd{
    int fd;       /*文件描述符*/
    short events; /*请求位掩码*/
    short revents;/*返回位掩码*/
}

参数 nfds 指定了数组 fds 中元素的个数。数据类型 nfds_t 实际为无符号整形。 pollfd 结构体中的 events 和 revents 字段都是位掩码。调用者初始化 events 来指定需要为描述符 fd 做检查的事件。当 poll()返回时, revents 被设定以此来表示该文件描述符上实际发生的事件。 表 63-2 列出了可能会出现在 events 和 revents 字段中的位掩码。该表中第一组位掩码( POLLIN、 POLLRDNORM、 POLLRDBAND、 POLLPRI 以及 POLLRDHUP)同输入事件相关。下一组位掩码( POLLOUT、 POLLWRNORM 以及 POLLWRBAND)同输出事件相关。 第三组位掩码( POLLERR、 POLLHUP 以及 POLLNVAL)是设定在 revents 字段中用来返回有关文件描述符的附加信息。如果在 events 字段中指定了这些位掩码,则这三位将被忽略。 在 Linux 系统中, poll()不会用到最后一个位掩码 POLLMSG。

63.2.3 文件描述符何时就绪

正确使用 select()和 poll()需要理解在什么情况下文件描述符会表示为就绪态。 SUSv3中说:如果对 I/O 函数的调用不会阻塞,而不论该函数是否能够实际传输数据,此时文件描述符(未指定 O_NONBLOCK 标志)被认为是就绪的。 select()和 poll()只会告诉我们 I/O操作是否会阻塞,而不是告诉我们到底能否成功传输数据。按照这个思路,让我们考虑一下这些系统调用在不同类型的文件描述符上所做的操作。我们将这些信息在表格中以两列来显示。 1.select()这一列表示文件描述符是否被标记为可读( r),可写( w)还是有异常情况( x)。 2.poll()这一列表示在 revents 字段中返回的位掩码。在这些表格中,我们忽略POLLRDNORM、 POLLWRNORM、 POLLRDBAND 以及 POLLWRBAND。尽管在很多情况下这些标志会在 revents 中返回(如果在 events 字段中指定过这些标志),但它们相对于 POLLIN、 POLLOUT、 POLLHUP 以及 POLLERR 来说,并没有提供更多有用的信息。

普通文件

代表普通文件的文件描述符总是被 select()标记为可读和可写。对于 poll()来说,则会在revents 字段中返回 POLLIN 和 POLLOUT 标志。原因如下。 1.read()总是会立刻返回数据、文件结尾符或者错误(例如,文件并没有因为读操作而打开)。 2.write()总是会立刻传输数据或者因出现某些错误而失败。

终端和伪终端

表 63-3 总结了在终端和伪终端上(见第 64 章) select()和 poll()的行为表现。 当伪终端对的其中一端处于关闭状态时,另一端由 poll()返回的 revents 将取决于具体实现。在 Linux 上至少会设置 POLLHUP 标志。但是,在其他实现上将返回各种不同的标志来表示这个事件—比如, POLLHUP、 POLLERR 或者 POLLIN。此外,在一些实现中,设定什么样的标志取决于被检查的是伪终端主设备还是从设备

管道和 FIFO

表 63-4 中总结了管道或 FIFO 的读端细节。“管道中有数据?”这一列表示管道中是否至少有 1 字节数据可读。在这个表格中,我们假设已经在 events 字段中指定了 POLLIN 标志。

在其他一些 UNIX 实现中,如果管道的写端是关闭状态,那么 poll()不会返回 POLLHUP, 而会返回 POLLIN 标志(因为 read()遇到文件结尾符会立刻返回)。可移植性高的程序应该检 查这两个标志从而得知 read()是否会阻塞。 表 63-5 总结了管道写端的细节。在这个表格中,我们假设已经在 events 字段中指定了 POLLOUT 标志。“有 PIPE_BUF 个字节的空间吗?”这一列表示管道中是否有足够剩余空间能 够以原子方式写入 PIPE_BUF 个字节而不会阻塞。这是 Linux 判定管道是否写就绪的标准方 法。其他一些 UNIX 实现也采用相同的标准;还有一些实现中认为只要可以写入 1 个字节,那 么管道就是写就绪的。(在 Linux 2.6.10 版之前,管道的负载能力就是 PIPE_BUF 个字节。这表示 如果管道只包含 1 字节数据,那么就认为它是不可写的。 ) 在其他一些 UNIX 实现中,如果管道的读端关闭,那么 poll()并不会返回 POLLERR 标志, 相反,要么会返回 POLLOUT,要么返回 POLLHUP。可移植的程序需要检查这些标志,以此来判断 write()是否会阻塞。 表 63-5: select()和 poll()在管道或 FIFO 写端上的通知

套接字

表 63-6 总结了 select()和 poll()在套接字上的行为表现。 对于 poll()这一列, 我们假设 events字段已经指定了( POLLIN | POLLOUT | POLLPRI)标志位。对于 select()这一列,我们假设需要检查文件描述符的输入、输出以及异常情况是否发生。(即,文件描述符在所有传递给 select()的 3 个集合中都有指定)。该表只涵盖了常见的情况,并不包含所有可能出现的情况。

Linux 专有的 POLLRDHUP 标志(从 Linux 2.6.17 以来就一直存在) 需要做进一步的解释。其实,这个标志的实际形式是 EPOLLRDHUP—主要被设计用于 epoll API 的边缘触发模式下(见 63.4 节)。当流式套接字连接的远端关闭了写连接时会返回该标志。使用这个标志能让采用了 epoll 边缘触发模式的应用程序使用更简洁的代码来判断远端是否已经关闭。(另一种可选的方法是,在应用程序中设定 POLLIN 标志,然后执行一次 read(),如果返回 0 则表示远端已经关闭了。 )

63.2.4 比较select()和 poll()

本节中,我们讨论一些 select()和 poll()之间的异同点。

实现细节

在 Linux 内核层面, select()和 poll()都使用了相同的内核 poll 例程集合。这些 poll 例程有别于系统调用 poll()本身。每个例程都返回有关单个文件描述符就绪的信息。这个就绪信息以位掩码的形式返回, 其值同 poll()系统调用中返回的 revents 字段中的比特值相关(见表 63-2)。

poll()系统调用的实现包括为每个文件描述符调用内核 poll 例程,并将结果信息填到对应的 revents字段中去。 为了实现 select(),我们使用一组宏将内核 poll 例程返回的信息转化为由 select()返回的与之对应的事件类型。 这些宏定义展现了 select()和 poll()所返回的信息之间的语义关系。 (观察 63.2.3 节的表格中 select()和 poll()这两列,可以发现每个系统调用提供的信息都同上述宏保持一致。 )唯一一点我们需要额外增加的是,如果被检查的文件描述符当中有一个关闭了, poll()会在 revents 字段中返回 POLLNVAL,而 select()会返回-1 且将错误码设为 EBADF。

API 之间的区别

以下是系统调用 select()和 poll()之间的一些区别。

1.select()所使用的数据类型 fd_set 对于被检查的文件描述符数量有一个上限限制( FD_SETSIZE)。在 Linux 下,这个上限值默认为 1024,修改这个上限需要重新编译应用程序。与之相反, poll()对于被检查的文件描述符数量本质上是没有限制的。

2.由于 select()的参数 fd_set 同时也是保存调用结果的地方,如果要在循环中重复调用select()的话, 我们必须每次都要重新初始化 fd_set。 而 poll()通过独立的两个字段 events(针对输入)和 revents(针对输出)来处理,从而避免每次都要重新初始化参数。

3.select()提供的超时精度(微秒)比 poll()提供的超时精度(毫秒)高。 (这两个系统调用的超时精度都受软件时钟粒度的限制。 )

4.如果其中一个被检查的文件描述符关闭了,通过在对应的 revents 字段中设定POLLNVAL 标记, poll()会准确告诉我们是哪一个文件描述符关闭了。 与之相反, select()只会返回-1,并设错误码为 EBADF。通过在描述符上执行 I/O 系统调用并检查错误码,让我们自己来判断哪个文件描述符关闭了。通常这些区别都不重要,因为应用程 序一般都会自己跟踪已经关闭的文件描述符。

可移植性

历史上, select()比 poll()使用得更加广泛。如今这两个接口都在 SUSv3 中标准化了,且都广泛存在于现代的 UNIX 实现中。但是如 63.2.3 节中提到的, poll()在不同的实现中行为上会有一些差别。

性能

当如满足如下两条中任意一条时, poll()和 select()将具有相似的性能表现。

1.待检查的文件描述符范围较小(即,最大的文件描述符号较低)。

2.有大量的文件描述符待检查,但是它们分布得很密集。(即,大部分或所有的文件描述符号都在 0 到某个上限之间)。 然而,如果被检查的文件描述符集合很稀疏的话, select()和 poll()的性能差异将变得非常明显。比如,最大文件描述符号 N 是个很大的整数,但在 0 到 N 之间只有 1 个或几个文件描述符要被检查。在这种情况下, poll()的性能表现将优于 select()。我们可以通过传递给这两个系统调用的参数来理解这其中的原因。在 select()中,我们传递一个或多个文件描述符集合,以及比待检查的集合中最大的文件描述符号还要大 1 的 nfds。 不管我们是否要检查范围 0 到 nfds-1之间的所有文件描述符, nfds 的值都不变。无论哪种情况,内核都必须在每个集合中检查 nfds个元素,以此来查明到底需要检查哪个文件描述符。与之相反,当使用 poll()时,只需要指定我们感兴趣的文件描述符即可,内核只会去检查这些指定的文件描述符。

我们将在 63.4.5 节中进一步讨论 select()和 poll()的性能, 在那一节中我们将比较这两个系统调用同 epoll 之间的性能差异

63.2.5 select()和 poll()存在的问题

系统调用 select()和 poll()是用来同时检查多个文件描述符就绪状态的方法,它们是可移植的、长期存在且被广泛使用的。但是当检查大量的文件描述符时,这两个 API 都会遇到一些问题。

1.每次调用 select()或 poll(),内核都必须检查所有被指定的文件描述符,看它们是否处于就绪态。当检查大量处于密集范围内的文件描述符时,该操作耗费的时间将大大超过接下来的操作。

2.每次调用 select()或 poll()时,程序都必须传递一个表示所有需要被检查的文件描述符的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序。 (此外,对于 select()来说,我们还必须在每次调用前初始化这个数据结构。 )对于 poll()来说,随着待检查的文件描述符数量的增加,传递给内核的数据结构大小也会随之增加。当检查大量文件描述符时,从用户空间到内核空间来回拷贝这个数据结构将占用大量的 CPU 时间。对于 select()来说,这个数据结构的大小固定为 FD_ SETSIZE,与待检查的文件描述符数量无关。

3.select()或 poll()调用完成后,程序必须检查返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪态了。上述要点产生的结果就是随着待检查的文件描述符数量的增加, select()和 poll()所占用的CPU 时间也会随之增加(更多细节请参见 63.4.5 节)。对于需要检查大量文件描述符的程序来说,这就产生了问题。

select()和 poll()糟糕的性能延展性源自这些 API 的局限性:通常,程序重复调用这些系统调用所检查的文件描述符集合都是相同的,可是内核并不会在每次调用成功后就记录下它们。 我们接下来要讨论的信号驱动 I/O 以及 epoll 都可以使内核记录下进程中感兴趣的文件描述符,通过这种机制消除了 select()和 poll()的性能延展问题。这种解决方案可根据发生的 I/O事件来延展,而与被检查的文件描述符个数无关。结果就是,当需要检查大量的文件描述符时,信号驱动 I/O 和 epoll 能提供更好的性能表现。

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