【计算机网络】非阻塞IO——select实现多路转接
🔥个人主页🔥:孤寂大仙V
🌈收录专栏🌈:计算机网络
🌹往期回顾🌹:【计算机网络】NAT、代理服务器、内网穿透、内网打洞、局域网中交换机
🔖流水不争,争的是滔滔不息
- 一、非阻塞IO与多路转接
- 二、select实现多路转接
- select函数
- 读就绪 写就绪
- 非阻塞服务器 代码实现
- 辅助数组
- 启动方法
- 事件派发
- accept套接字 添加到辅助数组
- 读数据
- 源码:[非阻塞http服务](https://gitee.com/hanbuxuan/linux-warehouse/tree/master/Project/Network/Non-blocking%20IO/Select%20Server)
- 三、select 的缺点
一、非阻塞IO与多路转接
非阻塞IO
IO分为阻塞IO和非阻塞IO,IO=拷贝+等的时间。非阻塞IO允许程序发起IO操作(网络读写等)后立即返回,不等待操作完成,通过回轮询或事件通知处理结果。阻塞IO会因为条件不满足卡住,直到条件就绪。非阻塞IO的关键是程序可以在IO操作未完成时继续执行其他任务。
多路转接
多路转接是实现非阻塞IO的一种高效的方式,通过监控多个IO描述符,通知程序哪些描述符已经就绪(可读、可写),让程序按需处理,避免阻塞。
路转接是一种技术,允许程序同时监控多个输入/输出(IO)事件(如网络套接字、文件描述符),当某些事件就绪(如数据可读、可写)时通知程序处理,而无需为每个IO操作分配单独的线程或阻塞等待。
实现多路转接可以用select、poll、epoll、等方式监控多个IO描述符的事件,当某个描述符就绪(网路),程序收到通知,处理对应的事件。
用多路转接,可以极大提高效率,在单个线程中处理多个并发IO操作,避免阻塞等待或者每个连接都创建线程。用多路转接实现非阻塞IO,程序无需等待某个IO操作完成,可以立即处理其他就绪的IO事件。
二、select实现多路转接
select负责一件事,就是等一次可以等待多个fd。等待多个fd,有任意一个或多个fd的事件就绪就会通知上层,告诉程序的调用发,哪些fd可以io了。select是等待多个fd的一种就绪事件通知机制。
select函数
#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
select的返回值:大于0是几就表示有几个fd就绪了。等于0超时了,在timeout内没有fd就绪。小于0,select报错。
参数
-
nfds 是需要监视的最大的文件描述符值+1。
-
数 timeout 为结构 timeval, 用来设置 select()的等待时间。
- NULL:表示select没有timeout,select一直被阻塞,直到某个文件描述符发生了事件。
- 0:select 非阻塞,立即返回,检查当前是否有就绪事件。
- 设置事件:timeout时间内,阻塞等待,超时就非阻塞返回。
- readfds、writefds、excepfds、分别是关系的读事件、写事件、异常事件。
- 这三个参数的结构是一个整数数组,更严格来说是一个“位图”,使用位图中的位来表示监视的文件。
- 这是三个参数,是输入输出型参数,比如说读事件,输入的时候是从用户态到内核态输出的时候是从内核态到用户态。输入的时候告诉内核,你要帮我关心哪些fd上的哪些事件,比特位的位置表示fd编号,比特位的内容,表示是否关心。返回的时候内核告诉用户,你让我关心的哪些fd上面的读事件已经就绪了,比特位的位置,表示fd的编号,比特位的内容,表示是否就绪。
- 因为这个位图是输入输出型的,所以未来这个位图一定会被频繁变更。位图中有多少个比特位就决定了select最多关心多少个fd。
读就绪 写就绪
读就绪的含义
在 select 或 epoll 等多路转接机制中,“读就绪”表示文件描述符(如网络套接字、文件句柄)已准备好进行读取操作,即:
数据已到达,可以调用 read 或 recv 读取而不阻塞。
对于网络套接字,可能表示接收缓冲区有数据、连接建立完成、或连接关闭。
具体事件(以网络套接字为例):
- 数据到达:远程端发送数据到套接字的接收缓冲区(如客户端收到服务器响应)。
- 连接建立:监听套接字(server socket)收到新连接请求(如 accept 可立即执行)。
- 连接关闭:对端关闭连接,recv 返回0(EOF,端点文件)。
- 错误:套接字发生错误(如 ECONNRESET),可能通过读取检测。
写就绪的含义
定义:
在多路转接机制中,“写就绪”表示文件描述符(如网络套接字、文件句柄)已准备好进行写入操作,即:
数据可以写入而不阻塞,例如套接字的发送缓冲区有足够空间。
对于网络套接字,通常意味着可以调用 send 或 write 发送数据。
触发条件(以网络套接字为例):
- 发送缓冲区有空间:套接字的发送缓冲区(TCP/UDP buffer)未满,可以写入数据。
- 连接建立完成:客户端套接字完成连接(如 connect 后的TCP三次握手),可以发送数据。
- 错误状态:套接字发生错误(如 ECONNREFUSED),可能通过写操作检测。
非阻塞服务器 代码实现
# pragma once#include "Common.hpp"
#include "Socket.hpp"
#include "Log.hpp"using namespace std;
using namespace LogModule;
using namespace SocketModule;class Selectserver
{
const static int size=8*sizeof(fd_set);
const static int defaultfd=-1;public:Selectserver(int port):_isrunning(false),_listensockfd(make_unique<TcpSocket>()){_listensockfd->BuildTcpSocketServer(port);for(int i=0;i<size;i++){_fd_array[i]=defaultfd;}_fd_array[0]=_listensockfd->FD();} void Start(){_isrunning=true;while(_isrunning){fd_set rfds; //定义fds集合FD_ZERO(&rfds);//清空集合int maxfd=defaultfd;for(int i=0;i<size;i++){if(_fd_array[i]==defaultfd) //遍历辅助数组,跳过-1的continue;FD_SET(_fd_array[i],&rfds); //每次进来都要重新设置rfdsif(maxfd<_fd_array[i]) //最大fd小于遍历到的fd更新{maxfd=_fd_array[i];} }PrintFd();int n=select(maxfd+1,&rfds,nullptr,nullptr,nullptr); //多路转接只关心读事件switch (n){case -1:LOG(LogLevel::ERROR)<<"select out";break;case 0:LOG(LogLevel::WARNING)<<"time out";default:LOG(LogLevel::INFO)<<"事件就绪";Dispatcher(rfds); //派发break;}}}void Dispatcher(fd_set& rfds) //事件派发{for(int i=0;i<size;i++){if(_fd_array[i]==defaultfd) continue; //跳过没有fd的位置if(FD_ISSET(_fd_array[i],&rfds)){if(_fd_array[i]==_listensockfd->FD()) //是listen套接字{Accepter();}else //accept套接字 干活{Recv(_fd_array[i],i);}} }}void Accepter() //accept套接字{InetAddr client;int sockfd=_listensockfd->AcceptOrDie(&client);//accept了干活fdLOG(LogLevel::DEBUG)<<"accept a new client"<<client.StringAddr();int pos=0;for(;pos<size;pos++){if(_fd_array[pos]==defaultfd) //遍历辅助数组,遇见-1跳出break;}if(pos==size){LOG(LogLevel::WARNING)<<"server full";close(sockfd);} else{_fd_array[pos]=sockfd;}}void Recv(int sockfd,int pos){char buffer[1024];ssize_t n=recv(sockfd,buffer,sizeof(buffer)-1,0); //收信息if(n>0){buffer[n] = 0;cout << "client say@ "<< buffer <<endl;}else if(n==0) //客户端退出{LOG(LogLevel::INFO)<<"client quit";_fd_array[pos]=defaultfd;close(sockfd);}else //出现错误 异常{LOG(LogLevel::FATAL)<<"recv error";_fd_array[pos]=defaultfd;close(sockfd);}}void PrintFd(){cout << "_fd_array[]: ";for (int i = 0; i < size; i++){if (_fd_array[i] == defaultfd)continue;cout << _fd_array[i] << " ";}cout << "\r\n";}~Selectserver(){}
private:unique_ptr<Socket> _listensockfd;int _fd_array[size]; //辅助数组bool _isrunning;
};
辅助数组
读事件的集合是个位图结构,每次调用select都会修改之前的结构,是有破坏性的。
保留“原始的 fd 集合”,因为 select() 会修改你传进去的集合。select() 会清除掉那些没就绪的 fd!如果你不提前备份,就会把原来的 fd 集合搞没,下一轮 select 就找不到了。
比如说读事件,一开机读到的是listen套接字,下一次就是accept套接字,每次调用 select 时只保留最新的 connfd(accept 套接字),而忘了继续监听 listenfd,那它就“没了”——不会再监视新的连接了。
上面的代码是基于之前写的TCP阻塞的服务器改的非阻塞服务器,没写应用层代码。
Selectserver(int port):_isrunning(false),_listensockfd(make_unique<TcpSocket>()){_listensockfd->BuildTcpSocketServer(port);for(int i=0;i<size;i++){_fd_array[i]=defaultfd;}_fd_array[0]=_listensockfd->FD();}
构造服务器,创建辅助数组数组内数据全部设置为-1,把listen套接字放到这个辅助数组中。
启动方法
void Start(){_isrunning=true;while(_isrunning){fd_set rfds; //定义fds集合FD_ZERO(&rfds);//清空集合int maxfd=defaultfd;for(int i=0;i<size;i++){if(_fd_array[i]==defaultfd) //遍历辅助数组,跳过-1的continue;FD_SET(_fd_array[i],&rfds); //每次进来都要重新设置rfdsif(maxfd<_fd_array[i]) //最大fd小于遍历到的fd更新{maxfd=_fd_array[i];} }PrintFd();int n=select(maxfd+1,&rfds,nullptr,nullptr,nullptr); //多路转接只关心读事件switch (n){case -1:LOG(LogLevel::ERROR)<<"select out";break;case 0:LOG(LogLevel::WARNING)<<"time out";default:LOG(LogLevel::INFO)<<"事件就绪";Dispatcher(rfds); //派发break;}}}
启动服务器,一直进行循环,定义fds集合清空集合定义最大的文件描述符。for循环遍历辅助数组,如果不是规定的套接字跳过。把辅助数组中的套接字重新设置进fds集合中,标记最大的文件描述符。
select多路转接(这里只关心读事件),对返回值进行判断,返回值大于0对事件进行派发(就是读事件中的文件描述符是listen套接字的去干listen套接字该干的事,是accept套接字去干accept套接字该干的事)
事件派发
void Dispatcher(fd_set& rfds) //事件派发{for(int i=0;i<size;i++){if(_fd_array[i]==defaultfd) continue; //跳过没有fd的位置if(FD_ISSET(_fd_array[i],&rfds)){if(_fd_array[i]==_listensockfd->FD()) //是listen套接字{Accepter();}else //accept套接字 干活{Recv(_fd_array[i],i);}} }}
事件派发,遍历辅助数组,跳过没有fd的位置,FD_ISSET检测文件描述符是否在fds中就绪,然后判断是listen套接字还是accept的套接字。
accept套接字 添加到辅助数组
void Accepter() //accept套接字{InetAddr client;int sockfd=_listensockfd->AcceptOrDie(&client);//accept了干活fdLOG(LogLevel::DEBUG)<<"accept a new client"<<client.StringAddr();int pos=0;for(;pos<size;pos++){if(_fd_array[pos]==defaultfd) //遍历辅助数组,遇见-1跳出break;}if(pos==size){LOG(LogLevel::WARNING)<<"server full";close(sockfd);} else{_fd_array[pos]=sockfd;}}
有了accept的套接字要在辅助数组中填加accept的套接字。遍历辅助数组,遇见defaulted值就要把这个accept套接字添加进去。判断辅助数组是否满了。
读数据
void Recv(int sockfd,int pos){char buffer[1024];ssize_t n=recv(sockfd,buffer,sizeof(buffer)-1,0); //收信息if(n>0){buffer[n] = 0;cout << "client say@ "<< buffer <<endl;}else if(n==0) //客户端退出{LOG(LogLevel::INFO)<<"client quit";_fd_array[pos]=defaultfd;close(sockfd);}else //出现错误 异常{LOG(LogLevel::FATAL)<<"recv error";_fd_array[pos]=defaultfd;close(sockfd);}}void PrintFd(){cout << "_fd_array[]: ";for (int i = 0; i < size; i++){if (_fd_array[i] == defaultfd)continue;cout << _fd_array[i] << " ";}cout << "\r\n";}~Selectserver(){}
到recv接收信息,已经是非阻塞的了,客户端退出要把辅助数组这个位置置为defaulted关闭文件描述符,出现异常也是上述操作。
源码:非阻塞http服务
三、select 的缺点
- 每次调用 select, 都需要手动设置 fd 集合, 从接口使用角度来说也非常不便。
- 每次调用 select, 都需要把 fd 集合从用户态拷贝到内核态, 这个开销在 fd 很多时会很大
- 同时每次调用 select 都需要在内核遍历传递进来的所有 fd, 这个开销在 fd 很多时也很大
- select 支持的文件描述符数量太小。