I/O多路复用的理解
先讲一个大家都比较熟悉的例子吧
小时候咱们都喜欢看《亮剑》、《雪豹》这一类的抗日剧,里面八路军在自己的驻地周围都会24小时分布一些暗哨,每当有鬼子要进行扫荡或者清剿的时候暗哨就会赶紧告诉驻地的八路军,有敌人过来了,赶紧抄家伙干仗了。其实这就和咱们要讲的I/O多路复用有异曲同工之处。
在我们接触I/O多路复用以前,在处理一些比较多的连接或者请求的时候大多数人会去使用类似下面这种方式去处理:
while(1)
{
int cli_fd = accept(listen_fd, (struct sockaddr*)&addrCli, sizeof(addrCli));
...../*开启一个线程或者开启一个进程*/
}
这么做看着十分的清爽,而且实现起来也相当的简单,但是服务器的主程序会一直阻塞在accept这个函数上,然后会一直去等待connect的到来,这种方法虽然十分的简单,但是在轮询的过程中耗费了大量的CPU时间,并且效率还十分的低下,因此我们要想让我们的程序性能进一步的提升,我们必须去寻找其他的方法。
那么咱们能不能像八路军一样去设置一个暗哨,当有敌人,其实也不一定是敌人,也有可能是友军或者平民(请求),到来的时候通知咱们八路军(服务端)什么什么人来了,然后八路军采取不同的应对措施,是敌人就抄家伙干仗,是友军就支锅煮肉(不同的操作,可读可写异常等等)。这样的话服务端就不需要一会看看有没有请求或者数据过来,一儿看看有没有数据过来,在这种情况下咱们的I/O多路复用技术应运而生。
我们将要监视的套接字登记给系统,然后阻塞在那里,服务器进程不再主动的去询问套接字上有没有请求或数据,而是由系统去通知咱们的服务器进程,哪个哪个套接字上有数据可以读了哪个哪个套接字可以往上面写数据了,当没有数据可以读写的时候服务器进程不会去查询套接字状态,从而不会浪费CPU时间,效率提高的同时,我们对套接字的状态的把控也更加的精确。
今天要讲的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函数的参数会告诉内核:
- 我们所关心的文件描述符(通过FD_SET()注册到 readfds, writefds, exceptfds中)
- 对每个描述符,我们所关心的状态。(我们是要想从一个文件描述符中读或者写,还是关注一个描述符中是否出现异常)
- 我们要等待多长时间。(我们可以等待无限长的时间,等待固定的一段时间,或者根本就不等待)
从 select函数返回后,内核告诉我们一下信息:
- 对我们的要求已经做好准备的描述符的个数(select和poll不可以,必须遍历所有已经注册的套接字数组然后进行判断,epoll可以返回就绪的文件描述符个数)
- 对于三种条件哪些描述符已经做好准备.(读,写,异常)
nfds通常被设置为select所监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。nfds在大部分书上被解释为监听的文件描述符总数,自己一直疑问我明明就没加那么多的描述符,为啥要管那么多,个人猜错应该是只要是0——nfds内的文件描述符有状态的改变select都会去给往相应的状态fd_set中去添加,但是由于在select之前并未通过FD_SET将文件描述符注册到fd_set中,因此select在检测到事件给fd_set添加的时候会并不会给添加上,这里用到了位操作,后面会讲到。
readfds, writefds, exceptfds代表可读可写异常。
timeval类型如下
struct timeval
{
long tv_sec; /* 秒 */
long tv_usec; /* 微秒 */
};
有三种情况:
- timeout == NULL 等待无限长的时间。等待可以被一个信号中断。当有一个描述符做好准备或者是捕获到一个信号时函数会返回。如果捕获到一个信号, select函数将返回 -1,并将变量 erro设为 EINTR。
- timeout->tv_sec == 0 &&timeout->tv_usec == 0不等待,直接返回。加入描述符集的描述符都会被测试,并且返回满足要求的描述符的个数。这种方法通过轮询,无阻塞地获得了多个文件描述符状态。
- timeout->tv_sec !=0 或者 timeout->tv_usec!= 0 等待指定的时间。当有描述符符合条件或者超过超时时间的话,函数返回。在超时时间即将用完但又没有描述符合条件的话,返回 0。对于第一种情况,等待也会被信号所中断。
fd_set变量
咱们一起看一段比较不容易看的代码吧,这个是从sys/select.h中复制出来的
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
这个就是fd_set的内部实现。接下来咱们抽丝剥茧将这段代码精简成为容易读懂的代码。一些宏和变量的出处会标在注释当中,大家下来自己也可以研究研究。
typedef long int __fd_mask; //在sys/select.h中
#define __FD_SETSIZE 1024 //在typesizes.h中
#define __NFDBITS (8 * (int) sizeof (__fd_mask)) //sys/select.h中
typedef struct
{
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
#define __FDS_BITS(set) ((set)->fds_bits)
}fd_set;
我们可以看到fds_bits实际上就是long类型的数组。
在fd_set中一位代表一个文件描述符,long类型在32位机下为4个字节,在64位机下为8个字节,一个字节有8位,咱们要代表1024的描述符当然也需要1024位,因此这个fds_bits数组的大小应当为__FD_SETSIZE / __NFDBITS这么多。
__FDS_BITS的定义是为了便于直接引用该结构中的fds_bits,而不用关心内部具体的定义。
此刻咱们再来看看readset, writset, exceptset内部是什么样的吧
没错fd_set其实就是这么一回事,因为它的每一位分别表示一个文件描述符,因此我们也称它为文件描述符集。它也就相当于一个1024位的二进制数组。当我们想监听一个文件描述符上的可读事件时,就将对应文件描述符值的位上的值设为1,有点拗口大概就是这样
fd[文件描述符] = 1(假设readfds是一个二进制的数组的话)。
这样的设计非常的巧妙,但是有一个问题就是位操作十分的繁琐,我们应当有一个统一的函数来对其进行操作。于是引出了下面一系列的宏函数:
#include <sys/select.h>
void FD_CLR(int fd, fd_set *set); //将fd从文件描述符集中去除(将fd位 置为0)
int FD_ISSET(int fd, fd_set *set); //判断fd是否在该文件描述符集中(判断fd位是否为1)
void FD_SET(int fd, fd_set *set); //将fd加入该文件描述符集(将fd对应的位 置为1)
void FD_ZERO(fd_set *set); //清除该文件描述符集上所有的fd(将fd_set的所有的位 置为0)
既然都到这一步了,咱们干脆看看内部实现吧,不感兴趣的可以跳过这一步,因为在前面的注释当中对这几个函数的解释已经很明白了,这里只是分析源码是如何实现的
宏函数的实现
#define FD_SET(fd, fdsetp) __FD_SET (fd, fdsetp)
#define FD_CLR(fd, fdsetp) __FD_CLR (fd, fdsetp)
#define FD_ISSET(fd, fdsetp) __FD_ISSET (fd, fdsetp)
#define FD_ZERO(fdsetp) __FD_ZERO (fdsetp)
这这个只是对函数的一个再次封装,略过,可以在sys/select.h中找到
# define __FD_ZERO(set) \
do { \
unsigned int __i; \
fd_set *__arr = (set); \
for (__i = 0; __i < sizeof (fd_set) / sizeof (__fd_mask); ++__i) \
__FDS_BITS (__arr)[__i] = 0; \
} while (0)
这里的话其实很明显,就是用了一个for循环,将每个long类型赋值为0,从而完成清零工作
使用do…while是为了使__i变量仅存在于花括号中,从而不影响花括号外面对该变量的定义
其实在bits/select.h中还有关于__FD_ZERO的汇编实现,楼主看不懂,所以就不讲了
本段代码以及后面的代码都可以在bits/select.h中找到
#define __NFDBITS ( 8 *( int )sizeof(__fd_mask))
#define __FD_SETSIZE 1024
#define __FD_MASK(d) ((__fd_mask) (1UL << ((d) % __NFDBITS)))
typedef long int __fd_mask; //起别名,不解释
#define __FD_ELT(d) ((d) / __NFDBITS )
#define __FD_SET(d, set) \
((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))
#define __FD_CLR(d, set) \
((void) (__FDS_BITS (set)[__FD_ELT (d)] &= ~__FD_MASK (d)))
#define __FD_ISSET(d, set) \
((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)
__NFDBITS为一个long类型可以表示多少个位
__FD_SETSIZE定义fd_set结构能包含的描述符的最大个数
__FD_ELT(d)是为了求出该fd所在fds_bits的第几个元素
__FD_MASK(d)其实也就相当于对1乘以2的n次方,d%__NFDBITS就是为了求出要向左位移多少位(乘以2的多少次方)
下面的函数就都是一些位运算了,我们直接用gcc -E将这几个宏展开给大家看看,有兴趣的下来直接也可以试试
fd_set r;
__FD_ZERO(&r);
__FD_SET(1, &r);
__FD_SET(5, &r);
__FD_CLR(1, &r);
__FD_ISSET(1, &r);
__FD_ISSET(5, &r);
/*展开后*/
do { unsigned int __i; fd_set *__arr = (&r); for (__i = 0; __i < sizeof (fd_set) / sizeof (__fd_mask); ++__i) ((__arr)->fds_bits)[__i] = 0; } while (0);
(( void ) (((&r)->fds_bits)[((1) / ( 8 *( int )sizeof(__fd_mask)) )] |= ((__fd_mask) (1UL << ((1) % ( 8 *( int )sizeof(__fd_mask)))))));
(( void ) (((&r)->fds_bits)[((5) / ( 8 *( int )sizeof(__fd_mask)) )] |= ((__fd_mask) (1UL << ((5) % ( 8 *( int )sizeof(__fd_mask)))))));
((void) (((&r)->fds_bits)[((1) / ( 8 *( int )sizeof(__fd_mask)) )] &= ~((__fd_mask) (1UL << ((1) % ( 8 *( int )sizeof(__fd_mask)))))));
((((&r)->fds_bits)[((1) / ( 8 *( int )sizeof(__fd_mask)) )] & ((__fd_mask) (1UL << ((1) % ( 8 *( int )sizeof(__fd_mask)))))) != 0);
((((&r)->fds_bits)[((5) / ( 8 *( int )sizeof(__fd_mask)) )] & ((__fd_mask) (1UL << ((5) % ( 8 *( int )sizeof(__fd_mask)))))) != 0);
大家可以抽丝剥茧,分析一下。通过一些编译器或者插件自动匹配一下括号之后,其实这些展开后的宏只是看着难看而已,并没有那么的难懂。
到这里大家应该对select内部的原理已经有了一定的了解,咱们接着看
fd_set *readfds是指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的读变化的,即我们关心是否可以从这些文件中读取数据了,如果这个集合中有一个文件可读,select就会返回一个大于0的值,表示有文件可读,如果没有可读的文件,则根据timeout参数再判断是否超时,若超出timeout的时间,select返回0,若发生错误返回负值。可以传入NULL值,表示不关心任何文件的读变化。
fd_set *writefds是指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的写变化的,即我们关心是否可以向这些文件中写入数据了,如果这个集合中有一个文件可写,select就会返回一个大于0的值,表示有文件可写,如果没有可写的文件,则根据timeout参数再判断是否超时,若超出timeout的时间,select返回0,若发生错误返回负值。可以传入NULL值,表示不关心任何文件的写变化。
fd_set *exceptfds同上面两个参数的意图,用来监视文件错误异常。
struct timeval* timeout是select的超时时间,这个参数至关重要,它可以使select处于三种状态
第一, 若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止;
第二, 第二,若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值;
第三, timeout的值大于0,这就是等待的超时时间,即select在timeout时间内阻塞,超时时间之内有事件到来就返回了,文件无变化返回0,有变化返回一个正值;
返回值:
负值:select错误
正值:某些文件可读写或出错
0:等待超时,没有可读写或错误的文件
select模拟
说了这么多下来咱们模拟一下select的整个流程,方便起见假设fd_set只占一个字节。
1.先声明一个文件描述符集 fd_set read,FD_ZERO(&read),此时set为00000000
2.若fd = 1。执行FD_SET(fd, &set), set为00000010,从0开始计数
3.此时再加入fd = 5。 set变为00100010。
4.执行select(6, &read, NULL, NULL, NULL)阻塞
5.若fd = 1上发生了可读事件,select返回。此时set为00000010。没有可读事件发生的fd = 5被清空了
根据上面模拟的情况以及源码分析我们可以得出:
(1)可监控的文件描述符个数取决与__FD_SETSIZE的值。在我自己的电脑上我1024。
(2)将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,一是用于再select返回后,array作为源数据和fd_set进行FD_ISSET判断。二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始 select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
(3)可见select模型必须在select前循环array(加fd,取maxfd),select返回后循环array(FD_ISSET判断是否有时间发生)。(同2)