select和poll用法解析
初识select
系统提供select函数来实现多路转接输入/输出模型,它也是最早的多路转接模型,不过由于太过复杂以及明显的缺点,现在已经很少使用了。如今使用的是它的加强版poll
或者epoll
。不过我们还是要先学习select
,这样才能对后面的2种模型学起来比较快。
select函数的功能和调用顺序
使用select
函数时可以将多个文件描述符集中到一起统一监视。让内核等待多个事件中的任何一个发生,当有一个或多个事件发生或者等待时间超过设定时间后,内核唤醒应用进程开始处理。这里的监视也可以称为事件,发生监视项对应情况时,称“发生了事件”。
监视项目如下:
- 是否存在套接字接收数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
select
函数的使用方法与一般函数区别较大,更准确的说,它很难使用。但为了实现I/O复用服务器端,我们应该掌握select
函数。接下来介绍select
函数的调用方法和顺序,如图所示。
图中给出来从调用select
函数到获取结果所经过程。可以看到,调用select
函数前需要一些准备工作,调用后还需查看调用结果。接下来按照上述步骤一一讲解。
设置文件描述符
利用select
函数可以同时监视多个文件描述符。当然,监视文件描述符也可以视为监视套接字。此时首先需要将监视的文件描述符集中到一起。集中是也要按照事件项(接收、传输、异常)进行区分,即按照上述3种事件项分为3类。
使用fd_set
数组变量执行此项操作,如图所示。该数组是存有0和1的位数组即位图。
图中最左端的位表示文件描述符0(所在位置)。如果该位置设置为1,则表示该文件描述符是监视对象,所以图中文件描述符为1和3的是监视对象。
那么我们可以通过文件描述符的数字直接将值注册到fd_set
变量吗?这是不可以的,针对fd_set
变量的操作是以位为单位进行的,这也意味着直接操作该变量会比较繁琐。因此,在fd_set
变量中注册或更改值的操作都是由下列宏完成。
void FD_CLR(int fd, fd_set *set); //从参数set指向的变量值清除fd的信息
int FD_ISSET(int fd, fd_set *set); // 若参数set指向的变量中包含文件描述符fd的信息,则返回“真”
void FD_SET(int fd, fd_set *set); // 在参数set指向的变量中添加文件描述符fd的信息
void FD_ZERO(fd_set *set); // 将fd_set变量的所有位初始化为0
上述函数中,FD_ISSET
用于验证select
函数的调用结果。
设置检查(监视)范围及超时
我们先看一下select
函数的原型:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
需要头文件<sys/select.h>
。
参数:
- nfds:监视对象文件描述符的数量,最大的描述符数量+1。三个集合是读、写、异常。
- readfds:这是输入输出型参数。文件描述符的集合,内核只检测这个集合中文件描述符对应的读缓冲区。读集合一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收数据。
- writefds:也是输入输出型参数。文件描述符的集合,内核只检测这个集合中文件描述符对应的写缓冲区,如果不需要使用这个参数可以指定为NULL。
- exceptfds:文件描述符集指针,可以为
NULL
,集合内包含要监听异常事件的文件。 - timeout:调用select函数后,为了防止陷入无限阻塞的状态,传递超时信息。
返回值:
- 发生错误时返回-1,;超时返回时返回0。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。
上面的参数引出来了2个问题,一个是nfds
为什么要加1,还有一个是timeval
结构体是什么?
我们先解决第一个问题:这是因为select
或者poll
内部是基于一个线性表来做文件检测的,因此需要把最大文件描述符指定出来,在进行线性遍历的时候,这就是一个结束的标志,如果不告诉我遍历的最大值,那么就不知道在哪结束。还有一个原因是文件描述符的值是从0开始的。
接着看第二个问题,先来看timeval
的结构体。
struct timeval {time_t tv_sec; /* seconds */suseconds_t tv_usec; /* microseconds */
};
select
函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过声明上述结构体变量,将秒数填入tv_sec
成员,将微秒填入tv_usec
成员,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回,不过这种情况下,select函数返回0。
简单来说timeout参数有三种模式:
一直等待下去(timeout设为空指针时);等待一段固定时间;立即返回不做等待(轮询模式,秒数和微秒数设为0)。
例如设置struct timeval timeout = {5,0}
,意思是每隔5秒,timeout一次。
接下来看一看这三个缓冲区的作用分别是什么?
- 读缓冲区:检测里面有没有数据,如果有数据,该缓冲区对应的文件描述符就绪。
- 写缓冲区:检测写缓冲区是否可以写(有没有容量),如果有容量可以写,缓冲区对应的文件描述符就绪。
- 读写异常:检测读写缓冲区是否有异常,如果有,该缓冲区对应的文件描述符就绪。
开始的时候,我们提到过fe_set
是一个位图,注定了使用select
的时候,一定会有大量的位图操作,让用户关注内核传递的fd是否有就绪信息的。select
的第2,3,4个参数都是输入输出型参数,现在讲解第2个参数,其他2个参数都是类似的。
- 输入时:用户告诉内核,我给你的一个或者多个fd,你要帮我关心fd上面的读事件,如果读事件就虚了,你要告诉我。
- 输出时:内核告诉用户,你让我关心的多个fd中,有哪些已经就绪了,用户赶快来读取吧。
当用作输入时。比特位的位置,表示文件描述符编号;比特位的内容,0或1,表示是否需要内核关心。
当用作输出时,比特位的位置,表示文件描述符编号;比特位的内容,0或1,表示哪些用户关心的fd,上面的读事件已经就绪了。
调用select函数后查看结果
我们已经知道当select的返回值大于0时,说明相应数量的文件描述符发生变化。发生变化是指监视的文件描述符中发生了相应的监视事件。例如,通过select的第二个参数传递的集合中存在需要读数据的描述符时,就意味着文件描述符发生变化。
select函数返回正整数时,怎样获知哪些文件描述符发生了变化?向select函数的第二列到第四个参数传递的fd_set
变量中将产生下述变化。
由图可知,select函数调用完成后,向其传递的fd_set
变量中将发生变化。原来为1的所有位均变为0,但发生变化的文件描述符对应位除外。因此,可以认为值任为1的位置上的文件描述符发生了变化。
select的优缺点
缺点
- 文件描述符数量限制
因为select
函数使用一个fd_set
类型的数据结构来表示要监视的文件描述符集合,这个集合的大小通常是固定的,由系统的FD_SETSIZE
常量决定,如果想要修改,需要重新编写内核,非常麻烦。在大多数系统中,FD_SETSIZE
的默认值是 1024,这意味着select
最多只能同时监视 1024 个文件描述符。对应需要处理大量并发连接的应用程序(如高性能的网络服务器),这个限制会成为瓶颈,无法满足实际需求。 - 线性扫描效率低
当select
函数返回时,程序需要通过遍历所有被监视的文件描述符来确定哪些文件描述符发生了状态变化。这种线性扫描的方式时间复杂度为 O ( n ) O(n) O(n) ,其中n是被监视的文件描述符数量。随着文件描述符的数量增加。线性扫描的开销会增大,导致性能下降。 - 不支持文件描述符的动态添加和删除(主要)
在每次调用select
函数之前,都需要重新设置fd_set
集合,将需要监视的文件描述符添加到集合中。如果要动态添加或删除文件描述符,就需要重新构建整个fd_set
集合。这种操作方式不够灵活,增加了编程的复杂度,并且在动态添加或删除文件描述符时,可能会导致不必要的开销。
这种现象通过下面的图片就能反应出来。
调用select
函数后,并不是把发生变化的文件描述符单独集合到一起,而是通过观察作为监视对象的fd_set
变量的变化,找出发生变化的文件描述符,因此写代码时无法避免针对所有监视对象的循环语句。
优点
后面讲解的epoll
方式只能在Linux下提供支持,也就是说,改进的IO复用模型不具有兼容性。相反,大部分操作系统都支持select函数。只要满足或要求下面两个条件,即使在Lin平台也不应拘泥于epoll
。
- 服务端接入者少。
- 程序应具有兼容性。
实际并不存在适用于所有情况的模型,所以我们应理解好各种模型的优缺点,并具备合理运用这些模型的能力。
poll函数
poll的机制与select函数类似,与select在本质上没有多大差别,使用方法也类似。下面看看它们二者之间的对比:
- 内核对应文件描述符的检测也是以线性的方式进行轮询,根据描述符的状态进行处理。
- poll和select检测到文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件描述符的增加而线性增大,从而效率也会降低。
select
检测到文件描述符个数上限是1024,poll没有数量限制。select
可以跨平台使用,poll只能在Linux平台使用。
头文件是<poll.h>
函数原型:int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数:
- fds:这是一个
struct pollfd
类型的数组,里边存储了待检测的文件描述符的信息,这个数组有三个成员:
struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */
};
- fd:委托内核检测的文件描述符。
- events:委托内核检测到fd事件(输入、输出、异常),每一个事件有多个取值。
- revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果。
- nfds:表示fds数组的长度。
- timeout:表示poll函数的超时时间, 单位是毫秒(ms)。-1的话是一直阻塞,直到检测的集合中有就绪的文件描述符,解除阻塞;0的话,不阻塞,函数立即返回。
返回值:返回值小于0, 表示出错;返回值等于0, 表示poll函数等待超时;返回值大于0, 表示poll由于监听的文件描述符就绪而返回。
events和revents的取值:
总结
select
实现多路复用的方式是,将已连接的Socket
都放到一个文件描述符集合,然后调用select
函数将文件描述符集合拷贝到内核中,让内核来检查是否有网络事件发生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此Socket
标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态,然后用户态还需要再通过遍历的方法找到可读或可写的Socket
,然后再对其处理。
所以,对应Select
这种方式,需要进行2次遍历文件描述符集合,一次是在内核态里,一次是在用户态里,而且还会发生2次拷贝文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select
使用固定长度的BitsMap
,表示文件描述符集合,而且所支持的文件描述符的个数是有限的,在Linux中,由内核的FD_SETSIZE
限制,默认最大值是1024,只能监听0~1023
的文件描述符。
poll 不再用BitsMap
来存储所关注的文件描述符,取而代之用动态数据,以链表形式来组织,突破了select
的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是poll
和select
并没有太大的本质区别,都是使用线性结构
存储进程关注的Socket
集合,因此都需要遍历文件描述符集合来找到可读或可写的Socket
,时间复杂度为O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。