《Linux系统编程(第2版)》——2.10 I/O多路复用

简介: 应用通常需要在多个文件描述符上阻塞:在键盘输入(stdin)、进程间通信以及很多文件之间协调I/O。基于事件驱动的图形用户界面(GUI)应用可能会和成百上千个事件的主循环竞争[5]。

本节书摘来自异步社区《Linux系统编程(第2版)》一书中的第2章,第2.10节,作者:【美】Robert Love著,更多章节内容可以访问云栖社区“异步社区”公众号查看

2.10 I/O多路复用

应用通常需要在多个文件描述符上阻塞:在键盘输入(stdin)、进程间通信以及很多文件之间协调I/O。基于事件驱动的图形用户界面(GUI)应用可能会和成百上千个事件的主循环竞争[5]。

如果不使用线程,而是独立处理每个文件描述符,单个进程无法同时在多个文件描述符上阻塞。只要这些描述符已经有数据可读写,也可以采用多个文件描述符的方式。但是,要是有个文件描述符数据还没有准备好——比如发送了read()调用,但是还没有任何数据——进程会阻塞,而且无法对其他的文件描述符提供服务。该进程可能只是阻塞几秒钟,导致应用效率变低,影响用户体验。然而,如果该文件描述符一直没有数据,进程就会一直阻塞。因为文件描述符的I/O总是关联的(比如管道),很可能一个文件描述符依赖另一个文件描述符,在后者可用前,前者一直处于不可用状态。尤其是对于网络应用而言,可能同时会打开多个socket,从而引发很多问题。

试想一下如下场景:当标准输入设备(stdin)挂起,没有数据输出,应用在和进程间通信(IPC)相关的文件描述符上阻塞。只有当阻塞的IPC文件描述符返回数据后,进程才知道键盘输入挂起——但是如果阻塞的操作一直没有返回,又会发生什么呢?

如前所述,非阻塞I/O 是这种问题的一个解决方案。使用非阻塞I/O,应用可以发送I/O请求,该请求返回特定错误,而不是阻塞。但是,该方案效率不高,主要有两个原因:首先,进程需要连续随机发送I/O操作,等待某个打开的文件描述符可以执行I/O操作。这种设计很糟糕。其次,如果进程睡眠则会更高效,睡眠可以释放CPU资源,使得CPU可以处理其他任务,直到一个或多个文件描述符可以执行I/O时再唤醒进程。

下面我们一起来探讨I/O多路复用。

I/O多路复用支持应用同时在多个文件描述符上阻塞,并在其中某个可以读写时收到通知。因此,I/O多路复用成为应用的关键所在,在设计上遵循以下原则。

1.I/O多路复用:当任何一个文件描述符I/O就绪时进行通知。

2.都不可用?在有可用的文件描述符之前一直处于睡眠状态。

3.唤醒:哪个文件描述符可用了?

4.处理所有I/O就绪的文件描述符,没有阻塞。

5.返回第1步,重新开始。

Linux提供了三种I/O多路复用方案:select、poll和epoll。本章先探讨select和poll,epoll是Linux特有的高级解决方案,将在第4章详细说明。

2.10.1 select()
select()系统调用提供了一种实现同步I/O多路复用的机制:

screenshot

在给定的文件描述符I/O就绪之前并且还没有超出指定的时间限制,select()调用就会阻塞。

监视的文件描述符可以分为3类,分别等待不同的事件。对于readfds集中的文件描述符,监视是否有数据可读(即某个读操作是否可以无阻塞完成);对于writefds集中的文件描述符,监视是否有某个写操作可以无阻塞完成;对于exceptfds中的文件描述符,监视是否发生异常,或者出现带外(out-of-band)数据(这些场景只适用于socket)。指定的集合可能是NULL,在这种情况下,select()不会监视该事件。

成功返回时,每个集合都修改成只包含相应类型的I/O就绪的文件描述符。举个例子,假定readfds集中有两个文件描述符7和9。当调用返回时,如果描述符7还在集合中,它在I/O读取时不会阻塞。如果描述符9不在集合中,它在读取时很可能会发生阻塞。(这里说的是“很可能”是因为在调用完成后,数据可能已经就绪了。在这种场景下,下一次调用select()就会返回描述符可用。)[6]

第一个参数n,其值等于所有集合中文件描述符的最大值加1。因此,select()调用负责检查哪个文件描述符值最大,将该最大值加1后传给第一个参数。

参数timeout是指向timeval结构体的指针,定义如下:

screenshot

如果该参数不是NULL,在tv_sec秒tv_usec微秒后。select()调用会返回,即使没有一个文件描述符处于I/O就绪状态。返回时,在不同的UNIX系统中,该结构体是未定义的,因此每次调用必须(和文件描述符集一起)重新初始化。实际上,当前Linux版本会自动修改该参数,把值修改成剩余的时间。因此,如果超时设置是5秒,在文件描述符可用之前已逝去了3秒,那么在调用返回时,tv.tv_sec的值就是2。

如果超时值都是设置成0,调用会立即返回,调用时报告所有事件都挂起,而不会等待任何后续事件。

不是直接操作文件描述符集,而是通过辅助宏来管理。通过这种方式,UNIX系统可以按照所希望的方式来实现。不过,大多数系统把集合实现成位数组。

FD_ZERO从指定集合中删除所有的文件描述符。每次调用select()之前,都应该调用该宏。

screenshot

FD_SET向指定集中添加一个文件描述符,而FD_CLR则从指定集中删除一个文件描述符。

screenshot

设计良好的代码应该都不需要使用FD_CLR,极少使用该宏。

FD_ISSET检查一个文件描述符是否在给定集合中。如果在,则返回非0值,否则返回0。当select()调用返回时,会通过FD_ISSET来检查文件描述符是否就绪:

screenshot

由于文件描述符集是静态建立的,所以文件描述符数存在上限值,而且存在最大文件描述符值,这两个值都是由FD_SETSIZE设置。在Linux,该值是1024。我们将在本章稍后一起来看各种不同限制。

返回值和错误码
select()调用成功时,返回三个集合中I/O就绪的文件描述符总数。如果给出了超时设置,返回值可能是0。出错时,返回-1,并把errno值设置成如下值之一:

EBADF

某个集合中存在非法文件描述符。

EINTR

等待时捕获了一个信号,可以重新发起调用。

EINVAL

参数n是负数,或者设置的超时时间值非法。

ENOMEM

没有足够的内存来完成该请求。

select()示例
我们来看看下面的示例代码,虽然简单但对select()用法的说明却非常实用。在这个例子中,会阻塞等待stdin的输入,超时设置是5秒。由于只监视单个文件描述符,该示例不算I/O多路复用,但它很清晰地说明了如何使用系统调用:

screenshot
screenshot
screenshot

用select()实现可移植的sleep功能
在各个UNIX系统中,相比微秒级的sleep功能,对select()的实现更普遍,因此select()调用常常被作为可移植的sleep实现机制:把所有三个集都设置NULL,超时值设置为非NULL。如下:

screenshot

Linux提供了高精度的sleep机制。在第11章中,我们将详细说明它。

pselect()
select()系统调用很流行,它最初是在4.2BSD中引入的,但是POSIX标准在POSIX 1003.1g-2000和后来的POSIX 1003.1-2001中定义了自己的pselect()方法:

screenshot

pselect()和select()存在三点区别:

pselect()的timeout参数使用了timespec结构体,而不是timeval结构体。timespec结构体使用秒和纳秒,而不是秒和毫秒,从理论上讲更精确些。但实际上,这两个结构体在毫秒精度上已经不可靠了。

pselect()调用不会修改timeout参数。因此,在后续调用中,不需要重新初始化该参数。

select()系统调用没有sigmask参数。当这个参数设置为NULL时,pselect()的行为和select()相同。
timespec结构体定义如下:

screenshot

把pselect()添加到UNIX工具箱的主要原因是为了增加sigmask参数,该参数是为了解决文件描述符和信号之间等待而出现竞争条件(在第10章将深入讨论信号)。假设信号处理程序设置了全局标志位(大部分都如此),进程每次调用select()之前会检查该标志位。现在,假定在检查标志位和调用之间收到信号,应用可能会一直阻塞,永远都不会响应该信号。pselect()提供了一组可阻塞信号,应用在调用时可以设置这些信号来解决这个问题。阻塞的信号要等到解除阻塞才会处理。一旦pselect()返回,内核就会恢复老的信号掩码。

在Linux内核2.6.16之前,pselect()还不是系统调用,而是由glibc提供的对select()调用的简单封装。该封装对出现竞争的风险最小化,但是并没有完全消除竞争。当真正引入了新的系统调用pselect()之后,才彻底解决了竞争问题。

虽然和select()相比,pselect()有一定的改进,但大多数应用还是使用select(),有的是出于习惯,也有的是为了更好的可移植性。

2.10.2 poll()
poll()系统调用是System V的I/O多路复用解决方案。它解决了一些select()的不足,不过select()还是被频繁使用(还是出于习惯或可移植性的考虑):

screenshot

select()使用了基于文件描述符的三位掩码的解决方案,其效率不高;和它不同,poll()使用了由nfds个pollfd结构体构成的数组,fds指针指向该数组。pollfd结构体定义如下:

screenshot

每个pollfd结构体指定一个被监视的文件描述符。可以给poll()传递多个pollfd结构体,使它能够监视多个文件描述符。每个结构体的events变量是要监视的文件描述符的事件的位掩码。用户可以设置该变量。revents变量是该文件描述符的结果事件的位掩码。内核在返回时会设置revents变量。events变量中请求的所有事件都可能在revents变量中返回。以下是合法的events值:

POLLIN

有数据可读。

POLLRDNORM

有普通数据可读。

POLLRDBAND

有优先数据可读。

POLLPRI

有高优先级数据可读。

POLLOUT

写操作不会阻塞。

POLLWRNORM

写普通数据不会阻塞。

POLLBAND

写优先数据不会阻塞。

POLLMSG

有SIGPOLL消息可用。

此外,revents变量可能会返回如下事件:

POLLER

给定的文件描述符出现错误。

POLLHUP

给定的文件描述符有挂起事件。

POLLNVAL

给定的文件描述符非法。

对于events变量,这些事件没有意义,events参数不要传递这些变量,它们会在revents变量中返回。poll()和select()不同,不需要显式请求异常报告。

POLLIN | POLLPRI等价于select()的读事件,而POLLOUT | POLLWRBAND等价于select()的写事件。POLLIN等价于POLLRDNORM | POLLRDBAND,而POLLOUT等价于POLLWRNORM。

举个例子,要监视某个文件描述符是否可读写,需要把events设置成POLLIN | POLLOUT。返回时,会检查revents中是否有相应的标志位。如果设置了POLLIN,文件描述符可非阻塞读;如果设置了POLLOUT,文件描述符可非阻塞写。标志位并不是相互排斥的:可以同时设置,表示可以在该文件描述符上读写,而且都不会阻塞。

timeout参数指定等待的时间长度,单位是毫秒,不论是否有I/O就绪,poll()调用都会返回。如果timeout值为负数,表示永远等待;timeout为0表示poll()调用立即返回,并给出所有I/O未就绪的文件描述符列表,不会等待更多事件。在这种情况下,poll()调用如同其名,轮询一次后立即返回。

返回值和错误码
poll()调用成功时,返回revents变量不为0的所有文件描述符个数;如果没有任何事件发生且未超时,返回0。失败时,返回-1,并相应设置errno值如下:

EBADF

一个或多个结构体中存在非法文件描述符。

EFAULT

fds指针指向的地址超出了进程地址空间。

EINTR

在请求事件发生前收到了一个信号,可以重新发起调用。

EINVAL

nfds参数超出了RLIMIT_NOFILE值。

ENOMEM

可用内存不足,无法完成请求。

poll()示例
我们一起来看一下poll()的示例程序,它同时检测stdin读和stdout写是否会发生阻塞:

screenshot

运行后,生成结果如下(和期望一致):

screenshot

再次运行,这次把一个文件重定向到标准输入,可以看到两个事件:

screenshot

如果在实际应用中使用poll(),不需要在每次调用时都重新构建pollfd结构体。该结构体可能会被重复传递多次,内核会在必要时把revents清空。

ppoll()
类似于pselect()和select(),Linux也为poll()提供了ppoll()。然而,和pselect()不同,ppoll()是Linux特有的调用:

screenshot

类似于pselect(),timeout参数指定的超时时间是秒和纳秒,sigmask参数提供了一组等待处理的信号。

2.10.3 poll()和select()的区别
虽然poll()和select()完成相同的工作,但poll()调用在很多方面仍然优于select()调用:

  • poll()不需要用户计算最大文件描述符值加1作为参数传递给它。
  • poll()对于值很大的文件描述符,效率更高。试想一下,要通过select()监视一个值为900的文件描述符,内核需要检查每个集合中的每个位,一直检查900个位。
  • select()的文件描述符集合是静态的,需要对大小设置进行权衡:如果值很小,会限制select()可监视的最大文件描述符值;如果值很大,效率会很低。当值很大时,大的位掩码操作效率不高,尤其是当无法确定集合是否稀疏集合。[7]对于poll(),可以准确创建大小合适的数组。如果只需要监视一项,则仅传递一个结构体。
  • 对于select()调用,返回时会重新创建文件描述符集,因此每次调用都必须重新初始化。poll()系统调用会把输入(events变量)和输出(revents变量)分离开,支持无需改变数组就可以重新使用。
  • select()调用的timeout参数在返回时是未定义的。代码要支持可移植,需要重新对它初始化。而对于pselect(),不存在这些问题。

不过,select()系统调用也有些优点:

  • select()可移植性更好,因为有些UNIX系统不支持poll()。
  • select()提供了更高的超时精度:select()支持微秒级,poll()支持毫秒级。ppoll()和pselect()理论上都提供了纳秒级的超时精度,但是实际上,这两个调用的毫秒级精度都不可靠。
相关文章
|
21天前
|
算法 Linux C++
【Linux系统编程】解析获取和设置文件信息与权限的Linux系统调用
【Linux系统编程】解析获取和设置文件信息与权限的Linux系统调用
29 0
|
21天前
|
算法 Linux C++
【Linux系统编程】深入解析Linux中read函数的错误场景
【Linux系统编程】深入解析Linux中read函数的错误场景
196 0
|
21天前
|
Linux API C语言
【Linux系统编程】深入理解Linux 组ID和附属组ID的查询与设置
【Linux系统编程】深入理解Linux 组ID和附属组ID的查询与设置
28 0
【Linux系统编程】深入理解Linux 组ID和附属组ID的查询与设置
|
27天前
|
Linux 数据处理 C++
Linux系统编程 C/C++ 以及Qt 中的零拷贝技术: 从底层原理到高级应用(一)
Linux系统编程 C/C++ 以及Qt 中的零拷贝技术: 从底层原理到高级应用
68 0
|
27天前
|
存储 Linux 测试技术
无效数据处理之道:Linux系统编程C/C++实践探索(三)
无效数据处理之道:Linux系统编程C/C++实践探索
17 0
|
27天前
|
存储 测试技术 Linux
无效数据处理之道:Linux系统编程C/C++实践探索(二)
无效数据处理之道:Linux系统编程C/C++实践探索
30 0
|
27天前
|
安全 Linux 测试技术
无效数据处理之道:Linux系统编程C/C++实践探索(一)
无效数据处理之道:Linux系统编程C/C++实践探索
69 0
|
28天前
|
存储 监控 Linux
【Linux IO多路复用 】 Linux下select函数全解析:驾驭I-O复用的高效之道
【Linux IO多路复用 】 Linux下select函数全解析:驾驭I-O复用的高效之道
50 0
|
27天前
|
存储 Linux API
Linux系统编程 C/C++ 以及Qt 中的零拷贝技术: 从底层原理到高级应用(三)
Linux系统编程 C/C++ 以及Qt 中的零拷贝技术: 从底层原理到高级应用
26 1
|
27天前
|
消息中间件 Linux 数据处理
Linux系统编程 C/C++ 以及Qt 中的零拷贝技术: 从底层原理到高级应用(二)
Linux系统编程 C/C++ 以及Qt 中的零拷贝技术: 从底层原理到高级应用
29 1