I/O多路复用之select系统调用

I/O多路复用模型允许我们同时等待多个套接字描述符是否就绪。Linux系统为实现I/O多路复用提供的最常见的一个函数是select函数,该函数允许进程指示内核等待多个事件中的任何一个发生,并只有在一个或多个事件发生或经历一段指定的时间后才唤醒它。
作为一个例子,我们可以调用select,告知内核仅在下列情况发生时才返回:

  • 当集合{0, 4}中任意描述符准备好读时返回
  • 当集合{1, 2, 7}中任意描述符准备好写时返回
  • 已经历了10.2秒

也就是说,我们调用select可以告知内核我们对哪些描述符感兴趣以及等待多久时间。
select是一个复杂的函数,有许多不同的应用场景,我们将只讨论第一种场景:等待一组描述符准备好读。

1
2
3
4
5
6
7
8
9
#include <unistd.h>
#include <sys/types.h>

int select(int n, fd_set *fdset, NULL, NULL, struct timeval *timeout);

FD_ZERO(fd_set *fdset); // 将fdset初始为为空集合
FD_CLR(int fd, fd_set *fdset); // 从fdset清除fd
FD_SET(int fd, fd_set *fdset); // 将fd添加到fdset
FD_ISSET(int fd, fd_set *fdset); // fd是否存在于fdset

我们来看下select函数的参数。参数n指定需要测试的描述符的数目,测试的描述符范围从0到n-1。第二个参数fdset指定需要测试的可读描述符集合。当fdset集合中有描述符可读,或者经历了timeout时间时,select将返回。当select返回时,作为一个副作用,select修改了参数fdset指向的描述符集合,这时fdset变成由读集合中准备好可以读了的描述符组成。select函数的返回值则指明了就绪集合的基数。值得注意的是,由于这个副作用,我们必须每次在调用select时都更新读集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <sys/time.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
int listenfd, connfd;
int server_len, client_len;
struct sockaddr_in server_address;
struct sockaddr_in client_address;
fd_set readfds, testfds;

// 创建套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);

// 命名套接字
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(6240);
server_len = sizeof(server_address);
bind(listenfd, (struct sockaddr*)&server_address, server_len);

// 创建套接字队列
listen(listenfd, 5);

FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);

// 等待客户请求
while (1) {
char ch;
int fd;
int nread;

// 同时检查监听套接字和已连接套接字
testfds = readfds;
printf("server waiting\n");
int result = select(FD_SETSIZE, &testfds, (fd_set*)0, (fd_set*)0, (struct timeval*)0);
if (result < 1) {
perror("select error");
exit(1);
}

for (fd = 0; fd < FD_SETSIZE; fd++) {
// 检查哪个描述符可读
if (FD_ISSET(fd, &testfds)) {
// 如果是一个新的客户连接请求
if (fd == listenfd) {
client_len = sizeof(client_address);
connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_len);
FD_SET(connfd, &readfds);
printf("adding client on fd %d\n", connfd);
}
// 如果是旧的客户活动
else {
ioctl(fd, FIONREAD, &nread);
// 如果客户断开连接
if (nread == 0) {
close(fd);
FD_CLR(fd, &readfds);
printf("removing client on fd %d\n", fd);
}
// 客户请求数据到达
else {
read(fd, &ch, 1);
sleep(5);
printf("serving client on fd %d\n", fd);
ch++;
write(fd, &ch, 1);
}
}
}
}
}
}

上面的代码展示了如何使用select来编写多并发服务器的过程。服务器可以让select调用同时检查监听套接字和已连接套接字。一旦select指示有活动发生,就可以用FD_ISSET来遍历所有可能的文件描述符,以检查是哪个描述符上面有活动发生。
如果是监听套接字可读,这说明正有一个客户试图建立连接,此时就可以调用accept创建一个客户的已连接套接字而不用担心阻塞。如果是某个客户描述符准备好,这说明该描述符上有一个客户请求需要我们读取处理。如果读操作返回零字节,这表示有一个客户进程已结束,这时我们可以关闭该套接字并把它从描述符集合中删除。

参考资料

  1. 深入理解计算机系统,第2版,机械工业出版社
  2. Linux程序设计(第4版),Neil Matthew等著,人民邮电出版社,2010年
  3. UNIX 网络编程卷1:套接字联网API(第三版), W.Richard Stevens 等著