此文章对应五种IO模型的1.3:I/O多路复用select系统调用的讲解
目录
- 1. 初识select
- 2. select函数原型
- 2.1 参数解释
- 2.2 函数返回值
- 2.3 关于fd_set结构
- 2.4 理解readfds、writefds、exceptfds
- 3. 理解select执行过程
- 4. socket就绪条件
- 5. select的特点
- 6. select缺点
- 7. select使用示例
- 7.1 检测标准输入输出
- 7.2 使用select实现网络消息传送
1. 初识select
select 是系统提供的经典 IO 多路复用实现,通过 select 系统调用可搭建多路复用输入 / 输出模型,是实现单进程 / 线程管理多个 IO 操作的核心接口之一,核心特性如下:
- select 系统调用的核心作用,是让程序统一监视多个文件描述符的可读、可写、异常三类核心状态变化;
- 什么叫可读?底层有数据,读事件就绪
- 什么叫可写?底层有空间,写事件就绪
- 调用 select 后,进程 / 程序会有阻塞和非阻塞状态,非阻塞状态时的返回值有所不同;阻塞状态,直到被监视的文件描述符中有一个或多个发生了指定的状态变化,或达到预设的等待超时时间,阻塞才会解除。
总结:select就是:通过等待多个fd状态变化的一种就绪事件通知机制!
2. select函数原型
select 的函数原型如下(Linux 系统编程标准接口):
#include<sys/select.h>intselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,structtimeval*timeout);2.1 参数解释
nfds:需要监视的最大文件描述符值 + 1(文件描述符从 0 开始遍历检测,此参数用于限定内核检测的范围,提升效率);
readfds:可读文件描述符集合的指针,传入需监视的可读 fd,返回后仅保留发生可读状态变化的 fd;
writefds:可写文件描述符集合的指针,传入需监视的可写 fd,返回后仅保留发生可写状态变化的 fd;
exceptfds:异常文件描述符集合的指针,传入需监视的异常 fd,返回后仅保留发生异常状态变化的 fd;
注:以上三个集合参数,若无需监视对应状态,可传入
NULL。timeout:
timeval结构体指针,用于设置 select 的阻塞等待超时时间,内核会在该时间内等待 fd 状态变化,超出时间则直接返回,有三种核心取值:NULL:永久阻塞,直到被监视的 fd 中有状态变化才解除阻塞;- 时间值全为 0:非阻塞模式,立即检测所有 fd 状态并返回,不做任何等待;
- 设定具体时间值:超时阻塞,若超时前无 fd 状态变化,到点后自动返回。
补充:timeval 结构体定义
timeout 依赖的timeval结构体用于精确设置秒和微秒级超时时间,定义如下:
structtimeval{longtv_sec;// 秒longtv_usec;// 微秒(1秒=10^6微秒)};2.2 函数返回值
select 调用的返回值为 int 类型,不同返回值对应不同执行结果,仅返回值大于 0 时表示有文件描述符状态发生变化,具体说明如下:
- 返回值 > 0:执行成功,返回状态发生变化的文件描述符总数(可读、可写、异常状态的 fd 数量累加);
- 返回值 = 0:超时返回,在
timeout设定的时间内,无任何被监视的文件描述符状态发生变化,程序解除阻塞继续执行; - 返回值 = -1:调用失败,此时错误原因会存入系统全局变量
errno,传入的 readfds、writefds、exceptfds 和 timeout 参数值会变得不可预测,不可继续使用。
常见错误值(errno)及含义
select 调用失败时,errno常见的取值及对应错误原因如下:
- EBADF:传入的文件描述符为无效值,或对应的文件 / 套接字已被关闭;
- EINTR:select 调用过程中被系统信号中断(如收到 SIGINT、SIGCHLD 等信号);
- EINVAL:参数非法,常见原因是
nfds为负值,或timeout结构体中的时间值设置无效(如微秒数 tv_usec≥10^6); - ENOMEM:内核内存资源不足,无法完成 select 的相关初始化操作。
补充使用要点
若因EINTR导致 select 调用失败,通常可在代码中做重入处理(重新调用 select),这类情况属于可恢复的错误,并非真正的程序异常。
常见的程序片段如下:
fs_set readset;FD_SET(fd,&readset);select(fd+1,&readset,NULL,NULL,NULL);if(FD_ISSET(fd,readset)){……}2.3 关于fd_set结构
fd_set是系统为 select 专门定义的文件描述符集合类型,其底层实现为整数数组,本质是一块位图,通过位图中每一位的置 1 / 置 0来标识对应的文件描述符是否被纳入监视范围 ——位图的第 n 位,对应文件描述符 fd=n,位为 1 表示监视该 fd,位为 0 表示不监视。
为避免直接操作位图带来的位运算错误、提升代码可读性,系统提供了一组专用的接口函数来操作fd_set,无需开发者手动处理位运算,接口如下:
#include<sys/select.h>// 清除描述符集合set中,fd对应的位(置0,取消对该fd的监视)voidFD_CLR(intfd,fd_set*set);// 检测描述符集合set中,fd对应的位是否为1(非0表示已置1,0表示未置1)intFD_ISSET(intfd,fd_set*set);// 设置描述符集合set中,fd对应的位(置1,加入对该fd的监视)voidFD_SET(intfd,fd_set*set);// 将描述符集合set中所有位清0,初始化空集合voidFD_ZERO(fd_set*set);2.4 理解readfds、writefds、exceptfds
这三个参数是输入输出型参数
以readfds为例,其余同理:
输入时:
用户告诉内核,你要帮我关心哪些fd上的读事件
比特位的位置,表示fd编号,比特位的内容,表示是否关心。
例如:0000 1111,表示只关心0~3这四个fd
输出时:
内核告诉用户,你让我关心的哪些fd上面的读事件已经就绪
比特位的位置,表示fd的编号,比特位的内容,表示是否就绪
例如:0000 0010,表示只有fd=1的文件描述符的内容是就绪的。
fd_set是位图结构,一次性可以向里面添加多个fd
细节:
- 位图是输入输出的,所以,将来这个位图一定会被频繁变更
- 位图有多少个比特位,就决定了select最多能关心多少个fd。(fd_set是一个系统提供的数据类型(struct):fd_set大小固定!)
- readfds:如果把fd添加到readfds集合中,表示改fd,只关心读事件。告诉内核,你只需要帮我关心fd的读事件。
3. 理解select执行过程
理解 select 模型的核心,是掌握内核对 fd_set 的修改逻辑。为简化理解,我们做一个经典的简化假设:取fd_set的长度为 1 字节(实际系统中fd_set长度由宏FD_SETSIZE定义,Linux 默认对应 1024 个 fd),fd_set的每 1 个二进制位对应 1 个文件描述符 fd,位图的第 n 位对应文件描述符 fd=n,1 字节的fd_set最多可监视 fd=0~7 共 8 个文件描述符。
以下通过可读事件监视的完整步骤,直观演示 select 的执行过程:
- 定义并初始化 fd_set 集合:执行
fd_set set; FD_ZERO(&set);,将集合所有位清 0,此时 set 的二进制表示为0000 0000; - 添加需监视的 fd:若要监视 fd=5 的可读事件,执行
FD_SET(5, &set);,将第 5 位置 1,此时 set 变为0001 0000; - 继续添加监视 fd:再依次执行
FD_SET(2, &set);、FD_SET(1, &set);,将第 2 位、第 1 位置 1,此时 set 变为0001 0011; - 调用 select 阻塞等待:执行
select(6, &set, NULL, NULL, NULL);,进入阻塞状态。👉 关键参数呼应:nfds=6是监视的最大 fd(5)+1;后三个参数为 NULL,表示仅监视可读事件、不监视可写 / 异常事件、永久阻塞(直到有 fd 触发事件); - 内核检测到事件,select 解除阻塞并返回:若此时 fd=1、fd=2 上触发了可读事件,fd=5 无任何事件,select 会立即返回(返回值为 2,对应 2 个状态变化的 fd),内核会重写传入的 fd_set 集合,仅保留触发事件的 fd 对应位为 1,未触发的位全部清 0,此时 set 变为
0000 0011(fd=5 对应的位被清空)。
核心执行要点
select 的执行过程,本质是用户层设置监视集合 → 内核层检测 fd 状态 → 内核重写集合并返回的过程,其中最关键的是:
- select 调用返回后,原传入的 fd_set 会被内核覆盖重写,仅保留触发对应事件的 fd 位为 1,未触发事件的 fd 位会被强制清 0;
- 正因内核会重写 fd_set,每次重新调用 select 前,都需要重新执行 FD_ZERO 初始化 + FD_SET 设置监视 fd,否则会因脏数据导致监视异常。
4. socket就绪条件
select 监视 socket 触发的可读、可写、异常事件,本质是检测 socket 满足就绪条件,只有当 socket 符合对应就绪规则时,内核才会将其标记为状态变化,触发 select 返回。以下是 Linux 下 socket 触发可读、可写、异常事件的核心就绪条件,其中异常就绪为选学内容。
读就绪(socket 触发可读事件的条件)
满足以下任一条件,socket 即处于读就绪状态,对其执行无阻塞读操作可直接获取结果:
- socket 内核接收缓冲区中的数据字节数,大于等于低水位标记 SO_RCVLOWAT(Linux 系统默认值为 1 字节),此时无阻塞读会成功,且返回值大于 0;
- TCP 通信中,对端主动关闭连接(发送 FIN 包),此时对该 socket 执行读操作,会直接返回 0(表示读到流结束);
- 处于 listen 状态的监听 socket上有新的客户端连接请求,此时调用 accept () 可无阻塞获取新的连接描述符;
- socket 上存在未处理的错误,此时对其执行无阻塞读操作会返回 - 1,且系统全局变量 errno 会置为对应的错误码。
写就绪(socket 触发可写事件的条件)
满足以下任一条件,socket 即处于写就绪状态,对其执行无阻塞写操作可直接获取结果:
- socket 内核发送缓冲区的可用空闲字节数(剩余可写入空间),大于等于低水位标记 SO_SNDLOWAT(Linux 系统默认值为 1024 字节),此时无阻塞写会成功,且返回值大于 0;
- socket 的写端被关闭(如调用 shutdown (fd, SHUT_WR) 关闭写端,或 TCP 对端关闭连接),此时对该 socket 执行写操作会触发SIGPIPE 信号,若忽略该信号,写操作会返回 - 1;
- 对 socket 执行非阻塞 connect ()后,连接成功建立或连接失败,此时 socket 会触发写就绪,可通过 getsockopt () 获取连接结果;
- socket 上存在未处理的错误,此时对其执行无阻塞写操作会返回 - 1,且系统全局变量 errno 会置为对应的错误码。
异常就绪(选学)
socket 触发异常就绪的核心条件为:socket 接收到 TCP 带外数据(紧急数据)。
- 带外数据与 TCP 协议的紧急模式相关,TCP 协议头中的紧急指针字段用于标识带外数据的位置,内核会为带外数据开辟独立的缓冲区,不会与普通数据混存;
- 带外数据的传输优先级高于普通数据,触发异常就绪后,可通过 recv ()/recvmsg () 的 MSG_OOB 标志读取带外数据,同学们可课后自行收集相关资料深入学习。
5. select的特点
select 作为经典的 IO 多路复用实现,其设计特性决定了使用上的核心特点,同时也存在明显的固有局限性,核心特点如下:
可监视的文件描述符数量存在硬限制
select 能监视的最大 fd 数量,由
fd_set的底层大小决定,而fd_set的容量由系统宏FD_SETSIZE固定定义(与sizeof(fd_set)一一对应)。例如服务器上sizeof(fd_set)=512字节,每 1 位对应 1 个 fd,则最大可监视512*8=4096个文件描述符;常见 Linux 系统默认FD_SETSIZE=1024(即sizeof(fd_set)=128字节),默认最大监视 1024 个 fd。该限制为内核级硬限制,若需调整需重新编译内核,开发中一般不建议修改,这也是 select 在高并发场景下的主要缺陷。
必须额外维护独立数据结构保存待监视的 fd
由于 select 返回后,内核会清空 fd_set 中无事件发生的 fd 对应位,仅保留触发事件的 fd 位,导致原监视集合被覆盖无法复用。因此实际开发中,需在将 fd 加入 select 监视集合的同时,额外使用一个独立的数组(如 int 数组)保存所有待监视的 fd,该数组的核心作用有两点:
- 作为 select 返回后的检测源:select 返回后,遍历该数组,结合
FD_ISSET逐个判断数组中的 fd 是否在改写后的 fd_set 中,以此确定具体哪个 fd 触发了事件,避免盲目遍历所有可能的 fd,提升检测效率; - 作为重新初始化监视集合的数据源:每次调用 select 前,需先执行
FD_ZERO清空 fd_set,再遍历该数组,通过FD_SET将所有待监视 fd 重新加入集合;同时遍历数组的过程中,可同步获取待监视 fd 的最大值maxfd,为 select 的第一个参数nfds(maxfd+1)提供准确值。
- 作为 select 返回后的检测源:select 返回后,遍历该数组,结合
延伸使用要点
正因为 select 存在 “需重新初始化监视集合” 的特点,每次调用 select 都需要执行 FD_ZERO、FD_SET 的初始化操作,且需遍历数组获取 maxfd,这些操作会随着待监视 fd 数量的增加带来额外的性能开销,这也是 select 在高并发场景下性能下降的原因之一。
6. select缺点
select 虽实现了 IO 多路复用,但受限于设计年代的技术背景,存在多处固有缺陷,使其在高并发网络场景下性能表现不佳,核心缺点如下:
- 接口使用繁琐,需手动重复初始化 fd 集合:因 select 返回后内核会覆盖重写 fd_set,每次调用 select 前都必须手动执行 FD_ZERO 清空集合 + FD_SET 重新添加待监视 fd,且需额外维护独立数组保存 fd,从开发使用角度来说操作繁琐、代码冗余。
- fd 集合存在用户态到内核态的频繁拷贝开销:每次调用 select,都需要将用户态的 fd_set 完整拷贝到内核态,待监视的 fd 数量越多,拷贝的数据量越大,系统内存和 CPU 的开销会显著增加。
- 内核层存在全量 fd 遍历检测开销:内核接收到拷贝后的 fd_set 后,会逐个遍历所有待监视的 fd,检测其是否处于就绪状态,fd 数量越多,遍历的耗时越长,高并发场景下该操作会成为明显的性能瓶颈。
- 可监视的 fd 数量存在内核级硬限制:能监视的最大 fd 数由系统宏
FD_SETSIZE固定定义(默认多为 1024),该限制无法通过简单的代码修改突破,需重新编译内核,完全无法适配高并发的网络服务场景。
7. select使用示例
7.1 检测标准输入输出
只检测标准输入:
#include<stdio.h>#include<unistd.h>#include<sys/select.h>intmain(){fd_set read_fds;FD_ZERO(&read_fds);FD_SET(0,&read_fds);for(;;){printf("> ");fflush(stdout);intret=select(1,&read_fds,NULL,NULL,NULL);if(ret<0){perror("select");continue;}if(FD_ISSET(0,&read_fds)){charbuf[1024]={0};read(0,buf,sizeof(buf)-1);printf("input: %s",buf);}else{printf("error! invaild fd\n");continue;}FD_ZERO(&read_fds);FD_SET(0,&read_fds);}return0;}说明:
- 当只检测文件描述符0(标准输入)时,因为输入条件只有在你有输入信息的时候,才成立,所以如果一直不输入,就会产生超时信息。
7.2 使用select实现网络消息传送
- main函数创建SelectServer类型的智能指针svr
构造函数
- 创建TcpSocket套接字
- 将存储文件描述符的数组清空
- svr调用SelectServer类中的Start函数
- 创建select函数需要的变量,fd_set rfds
- 使用FD_ZERO函数将rfds清空
- select函数的第一个参数需要放置最大文件描述符+1,所以要判断并存放最大文件描述符
- 调用select函数,timeout使用nullptr(阻塞模式)
- 跟据select函数的返回值n来判断是否可以调用后续处理函数
- 当n>0时表示有事件就绪,调用Dispatcher事件派发器函数
- 找到已经就绪的文件描述符判断是连接就绪还是普通读事件就绪,并执行不同的后续函数
- 如果是连接就绪,调用Accepter连接管理器函数,添加连接;如果连接超过限制,则关闭连接请求
- 如果是普通事件就绪,调用IO处理器Recver函数。(此处只使用了读事件,功能是将收到的数据进行打印)
代码测试指令
make # 编译
./selectserver 8080 # 开启服务端
telnet 127.0.0.1 8080 # 本机客户端测试,新开终端;连接成功后输入任意数据,即可在服务端回显
【免费】linux网络-多路转接select的使用资源-CSDN下载
SelectServer.hpp
#pragmaonce#include<iostream>#include<memory>#include<unistd.h>#include"Socket.hpp"#include"Log.hpp"usingnamespaceSocketModule;usingnamespaceLogModule;classSelectServer{conststaticintsize=sizeof(fd_set)*8;conststaticintdefaultfd=-1;public:SelectServer(intport):_listensock(std::make_unique<TcpSocket>()),_isrunning(false){_listensock->BuildTcpSocketMethod(port);for(inti=0;i<size;i++){_fd_array[i]=defaultfd;}_fd_array[0]=_listensock->Fd();}voidStart(){_isrunning=true;while(_isrunning){// 因为:listensockfd 也是一个fd, 进程怎么知道listenfd上面有新连接到来呢?// auto res = _listensock->Accept(); // 我们在select 这里,可以进行accpet马?// 将listencoskfd添加到select内部, 让OS帮我关心listensockfd上面的读事件fd_set rfds;// 定义 读 fds集合FD_ZERO(&rfds);// 清空fdsintmaxfd=defaultfd;for(inti=0;i<size;i++){if(_fd_array[i]==defaultfd)continue;// 1.每次select之前,都要对rfds进行重置FD_SET(_fd_array[i],&rfds);// 有没有设置到内核中 ? 1 or 0// 2.最大fd,一定是变化的if(maxfd<_fd_array[i]){maxfd=_fd_array[i];// 更新出最大fd}}PrintFd();// struct timeval timeout = {2, 0};// select 返回之后,你怎么还知道哪些fd需要被添加到rfds,让select关心呢?// 所以:select要进行完整的设计,需要借助一个辅助数组!保存服务器历史获取过的所有的fdintn=select(maxfd+1,&rfds,nullptr,nullptr,nullptr);switch(n){case-1:LOG(LogLevel::ERROR)<<"select error";break;case0:LOG(LogLevel::INFO)<<"time out ...";break;default:LOG(LogLevel::DEBUG)<<"有事件就绪了..., n:"<<n;Dispatcher(rfds);break;}}_isrunning=false;}// 事件派发器voidDispatcher(fd_set&rfds){// 不仅仅是新连接到来,也包括读事件就绪 // 指定的文件描述符,在rfds里面,就证明该fd就绪了for(inti=0;i<size;i++){if(_fd_array[i]==defaultfd)continue;// fd合法,不一定就绪if(FD_ISSET(_fd_array[i],&rfds)){// fd_array[i] 上面一定是读就绪了// listensockfd 新连接到来,也是读事件就绪// sockfd 数据到来,读事件就绪if(_fd_array[i]==_listensock->Fd()){// listensockfd 新连接到来Accepter();}else{// 普通的事件就绪Recver(_fd_array[i],i);}}}}// 链接管理器:将新连接的文件描述符,添加到_fd_array数组中voidAccepter(){InetAddr client;intsockfd=_listensock->Accept(&client);// accept会不会阻塞if(sockfd>=0){// 获取新连接到来成功,然后呢??能不能直接read/recv?,coskfd是否读就绪,我们不清楚// 只有谁最清楚?未来sockfd上是否有事件就绪?答:select!// 将新的sockfd,托管给select!// 如何托管?将新的fd放入辅助数组!!LOG(LogLevel::INFO)<<"get a new link, sockfd: "<<sockfd<<", client is: "<<client.StringAddr();intpos=0;for(;pos<size;pos++){if(_fd_array[pos]==defaultfd)break;}if(pos==size){LOG(LogLevel::WARNING)<<"select server full";close(sockfd);}else{_fd_array[pos]=sockfd;}}}// IO处理器voidRecver(intfd,intpos){charbuffer[1024];// 我在这里读取的时候,会不会阻塞??ssize_t n=recv(fd,buffer,sizeof(buffer)-1,0);// recv写的时候有bug吗?if(n>0){buffer[n]=0;std::cout<<"client say@ "<<buffer<<std::endl;}elseif(n==0){LOG(LogLevel::INFO)<<"client quit...";// 1.不要让select在关心这个fd了_fd_array[pos]=defaultfd;// 2.关闭fdclose(fd);}else{LOG(LogLevel::ERROR)<<"recv error";// 1.不要让select在关心这个fd了_fd_array[pos]=defaultfd;// 2.关闭fdclose(fd);}}voidPrintFd(){std::cout<<"_fd_array[]: ";for(inti=0;i<size;i++){if(_fd_array[i]==defaultfd)continue;std::cout<<_fd_array[i]<<" ";}std::cout<<"\r\n";}voidStop(){_isrunning=false;}~SelectServer(){}private:std::unique_ptr<Socket>_listensock;bool_isrunning;int_fd_array[size];};Main.cc
#include"SelectServer.hpp"intmain(intargc,char*argv[]){if(argc!=2){std::cout<<"Usage: "<<argv[0]<<" port"<<std::endl;exit(USAGE_ERR);}uint16_tport=std::stoi(argv[1]);std::unique_ptr<SelectServer>svr=std::make_unique<SelectServer>(port);svr->Start();return0;}