前言:本文将详细解析 select 和 poll 系统调用的工作原理与性能瓶颈。由于 epoll 内核机制比较复杂(包含红黑树、就绪队列、回调机制及 LT/ET 模式等),内容量大,将为其单独撰写一篇文章,敬请关注后续更新!
IO多路复用的本质是使用一个执行流同时等待多个文件描述符就绪。它解决了阻塞IO中“一个连接需要一个线程”导致的资源消耗过大问题,也解决了非阻塞IO需要不断轮询导致的CPU利用率低的问题。
实现IO多路复用的常用三种方法:select/poll/epoll,接下来我们一一进行学习:
我们知道IO = 等+拷贝,而select只负责‘等’这个步骤,一次可以等待多个fd,有任意一个或多个fd就绪了告诉用户可以IO了。
select的本质:通过等待多个fd的一种就绪事件通知机制。
在默认情况下,接收缓冲区和发送缓冲区都是空的,因此默认情况下,读事件通常不就绪,而写事件通常就绪(因为发送缓冲区有空间)。接下来我们以等待读事件就绪为例子讲解select:
输入以下指令可查看select使用手册:
man select头文件:#include <sys/select.h>(该头文件声明了select系统调用,表明其为内核提供的系统级接口)
select接口:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);select参数:
nfds:传入所有需要等待的文件描述符中的最大文件描述符加1(内核通过该值确定需遍历的fd范围,避免无效遍历)
timeout:这是一个输入输出型参数,struct timeval类型成员如下:
struct timeval {
int tv_sec; /* seconds */
int tv_usec; /* microseconds */
};tv_sec:表示阻塞等待的秒数tv_usec:表示阻塞等待的微妙数。tv_sec+tv_usec1timeout作为输出型参数时表示的是剩余的时间。比如timeout传入的是5秒,而只等了2秒就有文件就绪并进行返回,那么返回的剩余的时间就是3秒。readfds/writefds/exceptfds:
这三个参数都是fd_set类型的输入输出型参数,用法是一样的,这里就以readfds为例进行讲解
readfds:只关心读事件。writefds:只关心写事件。exceptfds:只关心异常事件。select是管理多个描述符的,怎么传入多个描述符?
首先我们需要清楚fd_set类型,这是一个文件描述符集合,是内核提供给用户的数据结构,我们需要向fd_set里添加需要监控的fd,而fd本质是数组下标(即0,1,2,3…),什么结数据结构可以表示这些信息呢?所以fd_set是位图结构,内存紧凑、操作高效。
fd_set位图是怎么表示某个描述符是否被关心呢?
比如这样一段比特位:0000 1000,作为输入型参数表示3号文件描述符被关心;作为输出型参数表示3号文件描述符已就绪。
细节:
sizeof(fd_set)*8可以查看,通常是1024。虽然select可关心的描述符有上限,但有的老内核只支持select,select有很好的跨平台性。select返回值:
这里我们仅仅讲解核心代码部分,突出重点,如下:
class SelectServer
{
public:
//完成初始化,打开套接字,端口绑定,打开监听...
void Start()
{
while(true)
{
//是否进行accept?
}
}
//......
private:
int _listenfd;
//......
}; 注意这里监听描述符_listenfd也是文件描述符,需要我们用select进行管理,而不是直接accept。
服务器在刚启动时,默认只有一个fd,accept本质是阻塞IO。accept是一个IO,只不过不是用来传输数据的,而它关心的是_listenfd的读事件。我们需要将_listenfd添加到select函数中,让select帮我关心读事件就绪。
首先定义一个fd_set位图,把_listenfd添加到位图里,然后把该位图作为readfds参数传入select中。注意:我们不能自己使用位操作把_listenfd添加到fd_set位图,而是使用OS提供的相应的接口,如下:
void FD_CLR(int fd, fd_set* set):清除指定描述符。int FD_ISSET(int fd, fd_set* set):判断fd是否在fd_set集合里。void FD_SET(int fd, fd_set* set):设置fd到fd_set集合里void FD_ZERO(fd_set* set):清空fd_set集合。即:
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(_listenfd, &rfds);注意:这里没有设置到内核里,只是在用户栈上。
因为在此时只关心_listenfd的读事件,所以select的第一个参数只用填_listenfd+1,writefds和exceptfds部分填nullptr即可。这里我们使用非阻塞模式,即timeout为nullptr。
示例:
class SelectServer
{
public:
//完成初始化(创建套接字、绑定端口、开启监听)...
//......
void Start()
{
while(true)
{
//如果直接用accept会直接阻塞,我们使用select检测_listenfd读事件是否就绪
//1.定义rfds文件描述符集。
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(_listenfd, &rfds);
//2.执行select,把rfds设置到内核。
int n = select(_listenfd+1, &rfds, nullptr, nullptr, nullptr);
//3.处理select返回值
switch(n)
{
case -1:
std::cout<<"select fail"<<std::endl;
break;
case 0:
std::cout<<"time out..."<<std::endl;
break;
default:
std::cout<<"事件就绪..."<<std::endl;
//处理事件
//......
break;
}
}
}
//......
private:
int _listenfd;
//......
};如上代码如果事件就绪后不进行处理会出现死循环打印 “事件就绪…”
当有事件就绪,需要处理就绪事件,通常调用事件处理函数。比如以上场景我们需要做的就是进行accept,示例:
//调用事件处理函数:
HandlerEvent()
{
//调用accpet获取用户fd
}当select管理的fd越来越多,有会带来新的问题。因为select返回时rfds已经被内核修改,那么下次再设置rfds时怎么历史管理过那些fd呢?所以需要我们把受到管理的fd记录下来,这里就要用到一个辅助数组(其他数据结构也可以),辅助下一次设置rfds。
int _fd_array[FDSIZE],这里把FDSIZE设为1024。_fd_array:将数组初始化为全-1,然后把_fd_array[0]设置为_listenfd。注意:select第一个参数是被管理的文件描述符中最大值加1,所以需要从_fd_array中取到最大fd。 文件描述符集rfds的填写示例:
fd_set rfds;
FD_ZERO(&rfds);
int maxfd = -1;//存取最大fd
for (int i = 0; i < FDSIZE; i++)//遍历辅助数组
{
if (_fd_array[i] != -1)
FD_SET(_fd_array[i], &rfds);
maxfd = max(maxfd, _fd_array[i]);//找到最大fd
}那么我们怎么把新获取到的userfd(accept获取到的用户fd)托管给select呢? 只需要把userfd给辅助数组即可。如下:
_fd_array中的空位置。userfd;如果有则将空位置设置为userfd。示例:
for (int i = 0; i < FDSIZE; i++)//遍历辅助数组
{
if (_fd_array[i] == -1) //当有空位置时,把userfd添加上
{
_fd_array[i] = userfd;
std::cout<<"accept success fd = "<<userfd<<std::endl;
break;
}
else if (i == FDSIZE - 1) //如果不是空位置,而且遍历到底了,则关闭userfd,退出循环
{
std::cout<< "服务器繁忙..."<<std::endl;
close(userfd);
break;
}
} 当select管理的fd变多,我们可以通过返回值知道有多少个fd就绪,但并不知道是那个fd就绪,是读就绪还是写就绪。所以在事件处理函数HandlerEvent中我们还要判断,那些fd就绪?读就绪还是写就绪或者是异常?(这里只考虑读就绪)。
其次不同文件描述符就绪的处理方式不同,比如listenfd读就绪就要进行accept获取userfd,如果是userfd读就绪则需要读取接收缓冲区数据。需要针对不同描述符就绪做不同处理,所以需要我们重新设计HandlerEvent,示例:
void HandlerEvent(fd_set& rfds/*, fd_set& wfds*/)
{
for(int i=0; i<FDSIZE; i++)
{
//如果_fd_array[i]不合法则continue
if (_fd_array[i] == -1) continue;
//接下来判断是否读就绪
if(FD_ISSET(_fd_array[i], &rfds))
{
//能确定读就绪,接下来根据不同的描述符做不同处理。
if(_fd_array[i] == _listenfd)
{
//调用自定义Accept()......
}
else
{
//调用Read()......
}
}
}
}注意:
_fd_array中移除(即将_fd_array中值为userfd的位置改为值-1),然后再关闭userfd。注意:调用Read时就证明读就绪了,不会阻塞。但不能在Read循环读,而是只读一次。数据没读完还会触发就绪,会再次调用Read。
到这里程序的核心逻辑就完成了,没有多进程,没有多线程,却能同时处理多个IO请求,做出了多执行流的效果。没有进程/线程切换成本,也没有内核调度成本。
特点:
readfds/writefds/exceptfds作为源借助辅助数组判定fd是否就绪缺点:
poll的作用和效果与select类似,但其接口设计更简单,在某些场景下也更高效。
输入以下指令可查看poll使用手册:
man poll头文件:#include <poll.h>。
poll接口:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);poll参数:
timeout:和select中的timeout参数的作用相同,这里的timeout做了简化,是int类型,单位是毫秒。 fds:一个struct pollfd类型数组的起始地址nfds:数组元素个数poll返回值(同select):
关于struct pollfd类型,成员如下:
struct pollfd{
int fd;
short events;
short revents;
}fd:文件描述符events:输入型参数。用位图的思想标记需要关心的该fd的什么事件。revents:输出型参数。内核给用户返回已经就绪的事件。poll与select最大的区别就是把输入型参数和输出型参数分开了,不用繁琐的重置文件描述符集。 可关心的事件:
事件 | 描述 | 作为输入 | 作为输出 |
|---|---|---|---|
POLLIN | 数据(包括普通数据和优先数据)可读 | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Linux 不支持) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如 TCP 带外数据 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先数据)可写 | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP 连接被对方关闭,或者对方关闭了写操作,它由 GNU 引入 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起。比如普通的写端被关闭后,该端描述符上将收到 POLLHUP 事件 | 否 | 是 |
POLLNVAL | 文件描述符没有打开 | 否 | 是 |
如上事件的本质是比特位为1的宏(即都是2的次方数),所以可以通过位操作设置到events中。这里我们只用重点关注POLLIN(读事件)和POLLOUT(写事件)即可。

poll调用时,fd和events为有效输入,用户通过这两个字段告诉内核需关心该fd上的events事件(使用"|"运算符把事件添加到events中即可)。poll成功返回时fd和revents有效,内核告诉用户哪些fd上面的revents事件就绪(拿着revents使用"&"运算符去匹配事件即可)。
加头文件#include<poll.h>
定义数组,这里就用固定大小,即struct pollfd _fds[FDSIZE],FDSIZE设为4096。(也可以使用数组指针动态开辟内存大小)。
初始化数组(注意fd为-1时内核并不会关心该文件描述符,所以把数组fd字段全初始化为-1),如下:
for(int i=0; i<FDSIZE; i++)
{
_fds[i].fd = -1;
_fds[i].events = 0;
_fds[i].revents = 0;
}
_fds[0].fd = _listenfd;
_fds[0].events = POLLIN;Start函数:
void Start()
{
while(true)
{
int n = poll(&_fds, FDSIZE, 0);
//处理poll返回值
switch(n)
{
case -1:
std::cout<<"select fail"<<std::endl;
break;
case 0:
std::cout<<"time out..."<<std::endl;
break;
default:
std::cout<<"事件就绪..."<<std::endl;
//处理事件
HandlerEvent();
//......
break;
}
}
}事件处理(可在select基础上修改):
void HandlerEvent()
{
for(int i=0; i<FDSIZE; i++)
{
//如果_fds[i].fd不合法则continue
if (_fds[i].fd == -1) continue;
//接下来判断是否读就绪
if(_fds[i].revents&POLLIN)
{
//能确定读就绪,接下来根据不同的描述符做不同处理。
if(_fds[i].fd == _listenfd)
{
//调用Accept()......
}
else
{
//调用Read()......
}
}
}
}在Accept中要把新连接userfd托管给poll,只需要把userfd给_fds数组即可。如下:
示例:
for (int i = 0; i < FDSIZE; i++)
{
if (_fds[i].fd == -1)
{
_fds[i].fd = userfd;
_fds[i].events = POLLIN;
std::cout<<"accept success fd = "<<userfd<<std::endl;
break;
}
else if (i == FDSIZE - 1)
{
std::cout<< "服务器繁忙..."<<std::endl;
close(userfd);
break;
}
}在Read()中如果用户断开连接需要把userfd关闭,然后从_fds中移除(即把fd设为-1,events和revents设为0)。
解决了select什么问题:
缺点:
非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!
,
↩︎