IO复用之select函数剖析

zhyjc6于2020-03-27发布 约5447字·约12分钟 本文总阅读量

前言

我们都知道服务器是用来服务大众的,因而其必须支持用户的并发访问。而我们普通的 socket 是阻塞式的,即服务器在空闲时处于监听状态,有用户连接请求时则创建一个新的 socket 来与用户交互。在服务器与用户交互的这段时间,服务器的监听功能处于瘫痪状态(不能向外界做出反馈),也不能处理其它用户的请求,因为服务器在专注于 send() 和 recv() (或者 sendto()/recvfrom(),与用户交互)。而用户与服务器之间一般也不会很快就断开连接,并且两者之间的交互频率还低得惊人(相对于 cpu 频率),这显然是浪费了服务器的计算资源啊!如何才能更大效率的利用服务器呢?

使用fork()

方式1 是使用 fork() 函数创建子进程,外层循环控制服务器始终处于监听状态,一旦有用户连接请求,就创建子进程,子进程会复制父进程当前的环境,因而可以在子进程中处理与用户交互的细节,而在父进程中继续监听,等待其它连接请求。这种方式可以实现并发处理,但是使用多进程的方式,并且一个进程只处理一个用户的连接,难免显得太过于笨重。因为复制进程是比较费时费力的,但是你却用一个进程去处理一个小小的连接,未免太看不起进程了。

使用select()

于是就有了方式2,也就是我现在要研究的东西,他就是 select() 函数。我们先回顾 fork() 的处理过程,然后再想想如何改进。fork() 函数创建一个和当前进程几乎相同的子进程,我们就是在监听并 accept 客户端的连接后得到了连接客户端的 clientSocket,此时我们调用 fork() 创建子进程。然后子进程中也有一个 clientSocket,我们就是利用子进程中的 clientSocket 套接字来与客户端通讯实现并发处理的。但是我们擦亮眼睛发现,子进程中还有 listenSocket,客户端服务器地址、端口配置等等一系列信息都没有使用呢?偌大的一个进程我们只需要其中的一个 clientSocket,但是我们却为了它复制了整个进程,这也就是使用子进程处理并发效率低的原因之一。有没有办法把客户端的连接请求统一处理,把大量的 clientSocket 使用一个容器装起来,当其中某个 socket 有通讯需要的时候我们再找到它并用它来与对应的客户端通讯?有,就是 select() 处理机制。

使用 select() 函数可以将多个文件描述符(当然也包括 socket 套接字)集中到一起统一监视。使用步骤如下:

设置文件描述符

我们使用一个叫 fd_set 的数据结构(实际上是 long 型数组)来存储我们要监视的文件描述符。这里注意我们的文件描述符是 int 型的整数,但是在 fd_set 中是用位 (bit) 来表示文件描述符的。百度百科上说 fd_set 实际上是 long 型数组,那么它是如何使用 1bit 来存储 1 个文件描述符呢?为了测试我的猜想,我在linux环境下写了个测试程序:

  1 #include <sys/time.h>
  2 #include <sys/types.h>
  3 #include <unistd.h>
  4 
  5 #include <stdio.h>
  6 
  7 int main(int argc, char* argv[]) {
  8     fd_set fds;
  9     FD_ZERO(&fds);//初始化为0
 10     while (true) {
 11         int i;
 12         scanf("%d", &i);
 13         FD_SET(i, &fds);
 14         printf("%d\t->\t0x%X\n", i, *(long*)&fds);//强行解析数组中第一个long大小的数据
 15         FD_CLR(i, &fds);//每次清零
 16     }
 17     return 0;
 18 }

下面是其输出:

0
0	->	0x1
1
1	->	0x2
2
2	->	0x4
3
3	->	0x8
4
4	->	0x10
5
5	->	0x20
6
6	->	0x40
7
7	->	0x80
8
8	->	0x100
9
9	->	0x200
10
10	->	0x400
11
11	->	0x800
12
12	->	0x1000

由于我们每次设置了一个文件描述符后就清零了,因此上面的都只是一个文件描述符的情况,那么我们不清零,看看多个文件描述符并存是怎样一番景象(把上面的程序的清零注释掉):

  1 #include <sys/time.h>
  2 #include <sys/types.h>
  3 #include <unistd.h>
  4 
  5 #include <stdio.h>
  6 
  7 int main(int argc, char* argv[]) {
  8     fd_set fds;
  9     FD_ZERO(&fds);//初始化为0
 10     while (true) {
 11         int i;
 12         scanf("%d", &i);
 13         FD_SET(i, &fds);
 14         printf("%d\t->\t0x%X\n", i, *(long*)&fds);//强行解析数组中第一个long大小的数据
 15         //FD_CLR(i, &fds);//每次清零
 16     }
 17     return 0;
 18 }

继续看看输出:

0
0	->	0x1
0
0	->	0x1
1
1	->	0x3
1
1	->	0x3
2
2	->	0x7
5
5	->	0x27
15
15	->	0x8027

为了简单起见,上面的程序输出使用 16 进制表示,但是很容易转换到二进制(草稿纸上写一下结论就出来了)。从上面的输出我们可以知道,如果我们传入的文件描述符的取值是从 0 开始的非负整数,那么一个文件描述符的值加 1 就是其在fd_set中的内存相对位置(以bit为单位)。

例如:

开始传入文件描述符为0,那么从地址&fd_set起第(0+1)位就置为1,如果把前32位解析为long其值就为0x1,二进制形式:
(高位)0000 0000 0000 0001(低位)

再传入文件描述符为1,那么从地址&fd_set起第(1+1)位就置为1,如果把前32位解析为long其值就为0x3,二进制形式:
(高位)0000 0000 0000 0011(低位)

再传入文件描述符为2,那么从地址&fd_set起第(2+1)位就置为1,如果把前32位解析为long其值就为0x7,二进制形式:
(高位)0000 0000 0000 0111(低位)

再传入文件描述符为5,那么从地址&fd_set起第(5+1)位就置为1,如果把前32位解析为long其值就为0x27,二进制形式:
(高位)0000 0000 0010 0111(低位)

再传入文件描述符为15,那么从地址&fd_set起第(15+1)位就置为1,如果把前32位解析为long其值就为0x8027,二进制形式:
(高位)1000 0000 0010 0111(低位)

当然,文件描述符一定可能会大于 15,如果超出了 sizeof(long) 也不用担心,笔者认为我们只需要将 fd_set 看成是 bit 型数组,那么一切就好理解了。

理解了文件描述符在 fd_set 里的存储方式,那么我们要怎么把文件描述符放进去呢?难道要我们自己去用指针将对应的位置为 1?怎么可能那么复杂!实际上,在fd_set变量中注册或更改值的操作都可以由下列来完成:

  • void FD_ZERO(fd_set *fdset):将 fdset 的所有位初始化为 0 。
  • void FD_SET(int fd, fd_set *fdset):在 fdset 中注册文件描述符fd的信息。
  • void FD_CLR(int fd, fd_set *fdset):从 fdset 中清除文件描述符fd的信息。
  • int FD_ISSET(int fd, fdset *fdset):返回 fdset 中文件描述符fd对应的位的值(0 or 1)

指定监视范围

首先了解一下select函数及其参数含义:

#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int maxfd, fd_set *readset, fd_set *writeset,
           fd_set *exceptset, struct timeval *timeout);
/*
返回值:
    成功时返回3个set中文件描述符总数之和,失败时返回-1
参数:
    maxfd:所有文件描述符中最大值加1
    readset:用来读的文件描述符
    writeset:用来写的文件描述符
    exceptset:用来观察是否异常的文件描述符
    timeout:在该时间内若没有文件描述符发生变动则进入阻塞状态
*/

言归正传,文件描述符的监视范围与 select 函数的第一个参数 maxfd 有关。既然是范围,那么一定要知道要监视的文件描述符的数量吧?由于文件描述符的值是从 0 开始,并且每次新建文件描述符时,新的描述符的值会在旧的值上自增 1,故只需要将文件描述符中最大值加一就能得到数量了,这也是为什么 maxfd 解释为最大文件描述符的值加 1 的原因。select 函数就是通过第一个参数 maxfd 知道要监视的文件描述符的数量的。

设置超时

函数的超时时间与函数的最后一个参数有关,其中 timeval 结构体定义如下:

struct timeval {
	long tv_sec;   //seconds
	long tv_usec;  //microseconds
}

本来 select 函数只有在监视的文件描述符发生变化时才返回,如果没有发生变化,则进入阻塞状态直到有变化发生。设定超时时间就是为了防止这种事情发生,只要设定了超时时间,即使文件描述符未发生变化,如果超过了超时时间,select() 函数就会返回,此时返回值为 0。如果不想设置超时,则传入 NULL,表示阻塞直到有变化发生。如果将 timeval 的两个成员都设置为 0,那么 select 将立即返回。

调用select函数后查看调用结果

我们先弄清楚一个问题,select() 函数是如何知道哪些文件描述符发生了变化呢?假设传入的某个 fd_set 如下:

我们发现,调用 select() 函数后 fd_set 中的文件描述符除了发生变化的以外,其它的都被置为了 0,因此select() 函数就认为调用函数后值仍然为 1 的位置的文件描述符发生了变化。

select 成功时返回就绪(可读、可写、异常)的文件描述符的总数(三个参数的总数之和)。如果在超时时间内没有任何文件描述符就绪,那么 select 返回 0。如果 select 调用失败 那么返回 -1 并设置 errno。如果在 select 等待期间,程序接收到信号,则 select 立即返回 -1,并设置 errno 为 EINTR。

使用select()实现I/O复用服务端

select()的优缺点

优点

缺点

文件描述符就绪条件

哪些情况下文件描述符可以被认为是可读、可写或者是出现异常,对于 select 的使用非常关键。在网络编程中,下列情况下 socket 可读:

下列情况下 socket 可写:

而 select 能处理的异常情况只有一种:socket 上接收到带外数据

参考资料