上次我们虽然使用非阻塞式 I/O 解决了阻塞式 I/O 情况下并发读取文件所出现的问题,但依然不够完美,使得程序的 CPU 占用率特别高。解决这个问题,就要用到本文将要介绍的 I/O 多路复用方法。
何为 I/O 多路复用
I/O 多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作。I/O 多路复用技术是为了解决:在并发式 I/O 场景中进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程不阻塞于某个特定的 I/O 系统调用。
由此可知,I/O 多路复用一般用于并发式的非阻塞 I/O,也就是多路非阻塞 I/O,譬如程序中既要读取鼠标、又要读取键盘,多路读取。
我们可以采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作,分别是系统调用 select()和 poll()。 这两个函数基本是一样的,细节特征上存在些许差别!
I/O 多路复用存在一个非常明显的特征:外部阻塞式,内部监视多路 I/O。
select()函数介绍
系统调用 select()可用于执行 I/O 多路复用操作,调用 select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)。其函数原型如下所示:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
可以看出 select()函数的参数比较多,其中参数 readfds、writefds 以及 exceptfds 都是 fd_set 类型指针, 指向一个 fd_set 类型对象,fd_set 数据类型是一个文件描述符的集合体,所以参数 readfds、writefds 以及 exceptfds 都是指向文件描述符集合的指针,这些参数按照如下方式使用:
⚫ readfds 是用来检测读是否就绪(是否可读)的文件描述符集合;
⚫ writefds 是用来检测写是否就绪(是否可写)的文件描述符集合;
⚫ exceptfds 是用来检测异常情况是否发生的文件描述符集合。
Tips:异常情况并不是在文件描述符上出现了一些错误。
fd_set 数据类型是以位掩码的形式来实现的,但是,我们并不需要关心这些细节、无需关心该结构体成员信息,因为 Linux 提供了四个宏用于对 fd_set 类型对象进行操作,所有关于文件描述符集合的操作都是通过这四个宏来完成的:FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO (),稍后介绍!
如果对 readfds、writefds 以及 exceptfds 中的某些事件不感兴趣,可将其设置为 NULL,这表示对相应条件不关心。如果这三个参数都设置为 NULL,则可以将 select()当做为一个类似于 sleep()休眠的函数来使用, 通过 select()函数的最后一个参数 timeout 来设置休眠时间。
select()函数的第一个参数 nfds 通常表示最大文件描述符编号值加 1,考虑 readfds、writefds 以及 exceptfds 这三个文件描述符集合,在 3 个描述符集中找出最大描述符编号值,然后加 1,这就是参数nfds。
select()函数的最后一个参数 timeout 可用于设定 select()阻塞的时间上限,控制 select 的阻塞行为,可将 timeout 参数设置为 NULL,表示 select()将会一直阻塞、直到某一个或多个文件描述符成为就绪态;也可将其指向一个 struct timeval 结构体对象。
如果参数 timeout 指向的 struct timeval 结构体对象中的两个成员变量都为 0,那么此时 select()函数不会阻塞,它只是简单地轮训指定的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回。否则,参数 timeout 将为 select()指定一个等待(阻塞)时间的上限值,如果在阻塞期间内,文件描述符集合中的某一个或多个文件描述符成为就绪态,将会结束阻塞并返回;如果超过了阻塞时间的上限值,select()函数将会返回!
select()函数将阻塞知道有以下事情发生:
⚫ readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个称为就绪态;
⚫ 该调用被信号处理函数中断;
⚫ 参数 timeout 中指定的时间上限已经超时。
FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()
文件描述符集合的所有操作都可以通过这四个宏来完成,这些宏定义如下所示:
#include <sys/select.h>
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
这些宏按照如下方式工作:
⚫ FD_ZERO()将参数 set 所指向的集合初始化为空;
⚫ FD_SET()将文件描述符 fd 添加到参数 set 所指向的集合中;
⚫ FD_CLR()将文件描述符 fd 从参数 set 所指向的集合中移除;
⚫ 如果文件描述符 fd 是参数 set 所指向的集合中的成员,则 FD_ISSET()返回 true,否则返回 false。
文件描述符集合有一个最大容量限制,有常量 FD_SETSIZE 来决定,在 Linux 系统下,该常量的值为 1024。在定义一个文件描述符集合之后,必须用 FD_ZERO()宏将其进行初始化操作,然后再向集合中添加我们关心的各个文件描述符,例如:
fd_set fset; //定义文件描述符集合
FD_ZERO(&fset); //将集合初始化为空
FD_SET(3, &fset); //向集合中添加文件描述符 3
FD_SET(4, &fset); //向集合中添加文件描述符 4
FD_SET(5, &fset); //向集合中添加文件描述符 5
在调用 select()函数之后,select()函数内部会修改 readfds、writefds、exceptfds 这些集合,当 select()函数返回时,它们包含的就是已处于就绪态的文件描述符集合了。譬如在调用 select()函数之前,readfds 所指向的集合中包含了 3、4、5 这三个文件描述符,当调用 select()函数之后,假设 select()返回时,只有文件描述符 4 已经处于就绪态了,那么此时 readfds 指向的集合中就只包含了文件描述符 4。所以由此可知,如果要在循环中重复调用 select(),我们必须保证每次都要重新初始化并设置 readfds、writefds、exceptfds 这些集合。
select()函数的返回值
select()函数有三种可能的返回值,会返回如下三种情况中的一种:
⚫ 返回-1 表示有错误发生,并且会设置 errno。可能的错误码包括 EBADF、EINTR、EINVAL、EINVAL 以及 ENOMEM,EBADF 表示 readfds、writefds 或 exceptfds 中有一个文件描述符是非法的;EINTR 表示该函数被信号处理函数中断了,其它错误大家可以自己去看,在 man 手册都有相应的记录。
⚫ 返回 0 表示在任何文件描述符成为就绪态之前 select()调用已经超时,在这种情况下,readfds, writefds 以及 exceptfds 所指向的文件描述符集合都会被清空。
⚫ 返回一个正整数表示有一个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过 FD_ISSET()宏进行检查, 以此找出发生的 I/O 事件是什么。如果同一个文件描述符在 readfds,writefds 以及 exceptfds 中同时被指定,且它多于多个 I/O 事件都处于就绪态的话,那么就会被统计多次,换句话说,select()返回三个集合中被标记为就绪态的文件描述符的总数。
使用示例
示例代码演示了使用 select()函数来实现 I/O 多路复用操作,同时读取键盘和鼠标。程序中将鼠标和键盘配置为非阻塞 I/O 方式,本程序对数据进行了 5 次读取,通过 while 循环来实现。由于在 while 循环中会重复调用 select()函数,所以每次调用之前需要对 rdfds 进行初始化以及添加鼠标和键盘对应的文件描述符。
该程序中,select()函数的参数 timeout 被设置为 NULL,并且我们只关心鼠标或键盘是否有数据可读, 所以将参数 writefds 和 exceptfds 也设置为 NULL。执行 select()函数时,如果鼠标和键盘均无数据可读,则 select()调用会陷入阻塞,直到发生输入事件(鼠标移动、键盘上的按键按下或松开)才会返回。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/select.h>
#define MOUSE "/dev/input/event3"
int main(void){
char buf[100];
int fd, ret = 0, flag;
fd_set rdfds;
int loops = 5;
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将键盘设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的 flag
flag |= O_NONBLOCK; //将 O_NONBLOCK 标准添加到 flag
fcntl(0, F_SETFL, flag); //重新设置 flag
/* 同时读取键盘和鼠标 */
while (loops--) {
FD_ZERO(&rdfds);
FD_SET(0, &rdfds); //添加键盘
FD_SET(fd, &rdfds); //添加鼠标
ret = select(fd + 1, &rdfds, NULL, NULL, NULL);
if (0 > ret) {
perror("select error");
goto out;
}
else if (0 == ret) {
fprintf(stderr, "select timeout.\n");
continue;
}
/* 检查键盘是否为就绪态 */
if(FD_ISSET(0, &rdfds)) {
ret = read(0, buf, sizeof(buf));
if (0 < ret)
printf("键盘: 成功读取<%d>个字节数据\n", ret);
}
/* 检查鼠标是否为就绪态 */
if(FD_ISSET(fd, &rdfds)) {
ret = read(fd, buf, sizeof(buf));
if (0 < ret)
printf("鼠标: 成功读取<%d>个字节数据\n", ret);
}
}
out:
/* 关闭文件 */
close(fd);
exit(ret);
}
程序中分析 select()函数的返回值 ret,只有当 ret 大于 0 时才表示有文件描述符处于就绪态,并将这些处于就绪态的文件描述符通过 rdfds 集合返回出来,程序中使用 FD_ISSET()宏检查返回的 rdfds 集合中是否包含鼠标文件描述符以及键盘文件描述符,如果包含则表示可以读取数据了。 编译运行:
代码将鼠标和键盘都设置为了非阻塞 I/O 方式,其实设置为阻塞 I/O 方式也是可以的,因为 select()返回时意味此时数据是可读取的,所以非阻塞和阻塞两种方式读取数据均不会发生阻塞。