IO多路转接(select方案)
linux的三种多路转接方案
linux中常见的多路转接方案:select/poll/epoll
多路转接的核心作用:通过对多个文件描述符进行等待(手段),来通知上层——哪些fd已经就绪
多路转接的本质是一种对IO事件就绪的通知机制!!
select
系统调用介绍
在传统的阻塞 I/O 模型中,程序对一个文件描述符(如socket、管道、设备文件)执行 read
/write
时,若该 FD 无数据/不可写,程序会被内核阻塞,无法处理其他任务。
select
的核心价值是**“一次等待,监控多个 FD”**:程序可将多个 FD 加入监控集合,由内核统一等待其中任意一个 FD 就绪(满足 I/O 条件),再唤醒程序处理就绪的 FD,从而提升 CPU 利用率,实现“伪并发”。
select
是系统调用,需包含头文件 <sys/select.h>
或 <sys/time.h>
,函数原型如下:
#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
下面我们就来一个个地认识这些参数,我们先来认识几个好理解的
(1) int nfds
nfds = select等待的所有fd中最大的那个fd再加个1
比如我等待的三个文件的文件描述符分别是4 5 6,那么此时nfds = 6+1=7
(2) struct timeval *timeout
struct timeval
的定义如下
struct timeval {long tv_sec; // 秒数(seconds)long tv_usec; // 微秒数(microseconds,1秒=1e6微秒)
};
struct timeval *timeout
是一个输入输出型参数!也就是说你在用的时候不仅要编写它的输入值,还要关注他的输出值!
首先我们先来看一下输入,传入的timeout有三种情况
- timeout = NULL:select 会无限阻塞,直到监控集合中有 FD 就绪或程序被信号中断。
timeout.tv_sec = 0
且timeout.tv_usec = 0
:select 不阻塞,直接 “轮询” 一次监控集合,返回当前就绪的 FD 数量。timeout.tv_sec > 0
或timeout.tv_usec > 0
:select 阻塞指定时间,若超时前有 FD 就绪则提前返回,否则超时后返回 0。举个例子就是timeout.tv_sec = 5,此时的策略就是5秒以内阻塞等待。5秒一到立即返回当前等待的fd集合中就绪的fd数量
然后我们还要特别注意, struct timeval *timeout
作为输出型参数的含义——调用返回时,timeout 会被内核修改为剩余的未使用时间(即实际等待时间与请求等待时间的差值)。
举个例子,我之前select设置的等待策略是5秒之内阻塞等待,超过5秒立即返回。假如说在第3秒结束的时候,有一个fd就绪了,那么select就会提前返回,此时输出型参数timeout 就会被设为 tv_sec=2, tv_usec=0(剩余 2 秒未使用)。
(3) select的返回值
我们记select
的返回值为n,n的取值有下面三种情况
返回值类型 | 含义 |
---|---|
n > 0 | 监控集合中就绪的 FD 总数(需通过 FD_ISSET 逐个检查哪些 FD 就绪)。 |
0 | 超时时间到,且没有任何 FD 就绪。 |
-1 | 调用失败(需通过 errno 查看具体错误) |
其中errno的常见错误包括:
EINTR
:select
被信号中断(如程序收到SIGINT
信号),可重试调用。EBADF
:监控集合中包含无效的 FD(如已关闭的 FD)。EINVAL
:nfds
小于0
或timeout
的值非法(如tv_usec
超过1e6
)。
除此之外我们还要特别注意!参数struct timeval *timeout
是一个输入输出型参数!当select提前返回时,timeout里面存的就是剩余的等待时间
参数名 | 类型 | 作用说明 |
---|---|---|
nfds | int | 监控的最大 FD 编号 + 1(内核通过此值确定需遍历的 FD 范围,避免无效检查)。 |
readfds | fd_set* | 需监控“可读事件”的 FD 集合(若为 NULL ,表示不监控可读事件)。 |
writefds | fd_set* | 需监控“可写事件”的 FD 集合(若为 NULL ,表示不监控可写事件)。 |
exceptfds | fd_set* | 需监控“异常事件”的 FD 集合(如 socket 发生错误,若为 NULL ,表示不监控)。 |
timeout | struct timeval* | 超时时间,控制 select 的阻塞行为(见下文“超时机制”)。 |
(4) 关键数据结构 fd_set
:文件描述符集合
想要知道剩下的三个参数的含义。我们必须首先来认识一个关键的数据结构: fd_set
fd_set
是一个位图结构(本质是固定大小的数组),每一位对应一个 FD
- 若
fd_set
中的某个比特位为1
,表示该 FD 被加入监控集合(表示这个文件是select等待的文件之一) - 若
fd_set
中的某个比特位是0
,则表示不监控(表示select不等它)
在前面我们举的例子中,假如说select要等待文件描述符为4 5 6的这三个文件,那fd_set
位图中的第4 5 6位比特一定是1,其他的比特位就都是0
fd_set
这个结构体的大小是多大呢?
- 一般默认为
1024字节
,这也导致select
监控的 FD 数量存在上限(这是select
的核心缺陷之一
(5) fd_set *readfds, fd_set *writefds, fd_set *exceptfds
readfds / writefds / exceptfds
这三个参数的类型都是fd_set,也就是说他们仨都是文件描述符集,readfds是读文件描述符集,writefds是写文件描述符集,exceptfds是异常文件描述符集,他仨全都是输入输出型参数!
作为输入参数,这三个位图的含义
- readfds中第五位bit=1,表示的含义就是程序希望 select 监控 fd=5的文件的 “可读事件”
- writefds中第五位bit=1,表示的含义就是程序希望 select 监控 fd=5的文件 的 “可读事件”
- exceptfds中第五位bit=1,表示的含义就是程序希望 select 监控 fd=5的文件的异常事件
fd=5的可读事件,指的就是内核对fd=5对应文件的接收缓冲区中还有数据,可以对这个文件进行读操作
fd=5的可写事件,指的是内核对fd=5对应文件的发送缓冲区中还有剩余的空间,可以对这个文件进行写操作
fd=5 的异常事件,指的是文件描述符 5(FD 5)发生了特定的异常状态或错误,需要程序处理
作为输出型参数,这三个位图的含义
- readfds中第五位bit=1,表示的含义就是fd=5的这个文件 的 可读事件 已经就绪了,表示这个文件现在可读了(不用等,直接读)
- writefds中第五位bit=1,表示的含义是fd=5的这个文件 的 可写事件 已经就绪了,表示这个文件现在可写了(不用等,直接写)
- exceptfds中第五位bit=1,表示的含义就是fd=5的这个文件在读写过程中确实发生了特定的异常状态或错误,需要程序处理
注意!只有那些用户在输入时想要监控的那些比特位,在输出时有可能被置为一。比如说我一开始只想监控fd等于0~5的这几个文件的可读事件,但是在select退出时fd=8的文件的可读事件也就绪了,那在返回的时候,readfds中第八个比特位是0还是1呢?答案是0,因为select根本不关心8,无论退出的时候8的状态是怎样的,readfds中第八个比特位始终都是0
fd_set
操作宏
假如说我在调用之前想将读文件描述符集合中的第二个比特位置1,表示我想关心Fd等于2的这个文件的可读事件是否就绪,那么按照我们的直观想法,应该是将原来的位图和0x02这个数进行按位或操作。但是由于需兼容不同系统的位图布局,fd_set
的位操作无法直接通过位运算符实现,因此我们必须通过系统提供的宏函数来实现fd_set的位操作
宏函数 | 作用 |
---|---|
FD_ZERO(fd_set *set) | 清空 set 集合(将所有位设为 0 ),初始化集合前必须调用。 |
FD_SET(int fd, fd_set *set) | 将 FD fd 加入 set 集合(将对应位设为 1 )。 |
FD_CLR(int fd, fd_set *set) | 将 FD fd 从 set 集合中移除(将对应位设为 0 )。 |
FD_ISSET(int fd, fd_set *set) | 检查 FD fd 是否在 set 集合中(返回非 0 表示在集合中,即就绪)。 |
代码实现:基于select的echoserver
main函数代码
#include "SelectServer.hpp"// ./select_server 8080
int main(int argc, char *argv[])
{if(argc != 2){std::cout << "Usage: " << argv[0] << " port" << std::endl;return 1;}ENABLE_CONSOLE_LOG();uint16_t local_port = std::stoi(argv[1]);std::unique_ptr<SelectServer> ssvr = std::make_unique<SelectServer>(local_port);ssvr->Init();ssvr->Loop();return 0;
}
SelectServer代码
#pragma once#include <iostream>
#include <string>
#include <memory>
#include <sys/select.h>
#include "Log.hpp"
#include "Socket.hpp"using namespace SocketModule;
using namespace LogModule;#define NUM sizeof(fd_set) * 8const int gdefaultfd = -1;// 最开始的时候,tcpserver只有一个listensockfd
class SelectServer
{
public:// SelectServer构造函数SelectServer(int port): _port(port),_listen_socket(std::make_unique<TcpSocket>()),_isrunning(false){}// SelectServer初始化函数void Init(){// 监听套接字的初始化_listen_socket->BuildTcpSocketMethod(_port);// _fd_array数组的初始化for (int i = 0; i < NUM; i++)_fd_array[i] = gdefaultfd;// 将监听套接字添加到数组中,表示我每次调用select时都期望内核去检测一下监听套接字是否读就绪_fd_array[0] = _listen_socket->Fd();}void Loop(){fd_set rfds; // 读文件描述符集_isrunning = true;while (_isrunning){// 清空rfdsFD_ZERO(&rfds);struct timeval timeout = {10, 0};int maxfd = gdefaultfd;for (int i = 0; i < NUM; i++){if (_fd_array[i] == gdefaultfd)continue;// 合法的fdFD_SET(_fd_array[i], &rfds); // 包含listensockfd// 更新出最大值if (maxfd < _fd_array[i]){maxfd = _fd_array[i];}}// 我们不能让accept监听新链接,因为它是阻塞监听,因此我们要让select来负责进行就绪事件的检测// 用户告诉内核,你要帮我关心&rfds里面的读事件啊!!// 内核会帮我检测&rfds里面的fd,是否有读事件就绪了// 就绪了,我就把就绪的fd,告诉用户,我放在&rfds里面int n = select(maxfd + 1, &rfds, nullptr, nullptr, &timeout); // 通知上层的任务!switch (n){case 0:std::cout << "time out..." << std::endl;break;case -1:perror("select");break;default:// 有事件就绪了// rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!std::cout << "有事件就绪啦..., timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;Dispatcher(rfds); // 把已经就绪的sockfd,派发给指定的模块break;}}_isrunning = false;}// Accepter() 函数的主要功能就是调用accept函数从监听套接字中获取一个新连接,为其创建专门的通信套接字,负责后续的通信// 同时也将这个新创建的通信套接字的文件描述符fd加入到我们fd_array中// 告诉select以后你还要帮我关心这个新的fd上面的读事件是否就绪void Accepter() // 回调函数呢?{InetAddr client;// listensockfd就绪了!获取新连接不就好了吗?int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"if (newfd < 0)return;else{std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;// recv()?? 读事件是否就绪,我们并不清楚!newfd也托管给select,让select帮我进行关心新的sockfd上面的读事件就绪// 怎么把新的newfd托管给select?让select帮我去关心newfd上面的读事件呢?把newfd,添加到辅助数组即可!int pos = -1;for (int j = 0; j < NUM; j++){if (_fd_array[j] == gdefaultfd){pos = j;break;}}// 从左到右遍历,找到第一个默认值的位置,这就是后面我们要将新的fd添加到的位置if (pos == -1){LOG(LogLevel::ERROR) << "服务器已经满了...";close(newfd);}else{_fd_array[pos] = newfd;}}}// 如果这个就绪的fd不是监听套接字,说明这个fd就是我们前面调用accepter创建的一个新通信套接字,// 这时候如果他就绪了,说明这个新的通信套接字上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你处理呢// 这里我们就执行最简单的处理方法,即原封不动地把客户端发来的数据打印到我们的屏幕上void Recver(int who) // 回调函数?{// 合法的,就绪的,普通的fd// 这里的recv,对不对啊!不完善!必须得有协议!char buffer[1024];ssize_t n = recv(_fd_array[who], buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了if (n > 0){buffer[n] = 0;std::cout << "client# " << buffer << std::endl;// 把读到的信息,在回显会去std::string message = "echo# ";message += buffer;send(_fd_array[who], message.c_str(), message.size(), 0); // bug}else if (n == 0){LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fd_array[who];close(_fd_array[who]);_fd_array[who] = gdefaultfd;}else{LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " << _fd_array[who];close(_fd_array[who]);_fd_array[who] = gdefaultfd;}}// 事件分发器的作用:根据就绪的fd,去调用对应的回调函数// 如果这个就绪的fd是监听套接字,说明这时候监听套接字又收到了其他客户端发来的链接请求//这时候我们就调用accepter,从监听套接字中获取一个新请求,为其创建一个新的通信套接字// 同时也将这个新创建的通信套接字的文件描述符加入到我们的fd_array中// 如果这个就绪的fd不是监听套接字,说明这个fd就是我们前面调用accepter创建的一个新通信套接字,// 他就绪了,说明这个新的通信套接字上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你收呢// 这个时候我们就调用recver函数,去处理这个客户端的IO请求void Dispatcher(fd_set &rfds) // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了{// 遍历_fd_array[i],找出for (int i = 0; i < NUM; i++){if (_fd_array[i] == gdefaultfd)continue;// 文件描述符,先得是合法的if (_fd_array[i] == _listen_socket->Fd()){ // 走到这里说明,_fd_array[i]里面存的那个文件描述符,是listensockfd// 紧接着我们要查看listensockfd是否在rfds里面// 如果在,说明有新的连接到来if (FD_ISSET(_fd_array[i], &rfds)){Accepter(); // 连接的获取}}else{// 这个_fd_array[i]是我们曾经从监听套接字中读出的一次客户端请求,为了方便后续通信,// 我们在accepter函数中为了处理这个客户端的IO请求专门创建了一个新的套接字,后续服务器与客户端的这个IO请求之间的数据收发操作(如调用 recv 接收数据、调用 send 发送数据 )都将通过这个新的套接字文件描述符来进行。// 走到这里时就说明,这个新的套接字文件描述符上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你收呢// 这个时候我们就调用recver函数,去处理这个客户端的IO请求if (FD_ISSET(_fd_array[i], &rfds)){Recver(i); // IO的处理}}}}~SelectServer(){}private:uint16_t _port;std::unique_ptr<Socket> _listen_socket;bool _isrunning;int _fd_array[NUM]; // 辅助数组
};
select的特点与缺陷
select的特点
- 可监控的文件描述符个数取决于sizeof(fd_set)的值。我这边服务器上sizeof(fd_set) = 512,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是512*8=4096。
- 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,
- 一是用于再select返回后,array作为源数据和fd_set进行FD_ISSET判断。
- 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
备注:
fd_set的大小可以调整,可能涉及到重新编译内核,感兴趣的同学可以自己去收集相关资料。
select缺点
- 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便。
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- select支持的文件描述符数量太小。
缺陷 | 具体描述 |
---|---|
FD 数量上限 | 受 FD_SETSIZE 限制(默认 1024),虽可通过修改内核参数扩大,但会导致性能下降。 |
集合需重复初始化 | select 调用后会修改传入的 fd_set 集合(仅保留就绪的 FD),下次调用前必须重新清空并添加所有 FD,增加开销。 |
线性遍历效率低 | 每次 select 返回后,程序需通过 FD_ISSET 逐个遍历所有监控的 FD 才能找到就绪的 FD,当 FD 数量庞大时(如万级),遍历开销显著。 |
内核/用户空间拷贝 | select 每次调用需将 fd_set 从用户空间拷贝到内核空间,FD 数量越多,拷贝开销越大。 |
这里我就有一个问题。你进程打开的文件数本身不是也有上限吗?因为进程打开的文件是记录在PCB中的进程打开文件表中的,更具体来说是记录在int fd_array[]数组中的,只要是数组就一定会有上限。因此select能监控的文件数量也会受到进程最大打开文件数的限制,既然如此,那你怎么还说Fd数量的上限是select的一个缺陷呢?
int fd_array[]数组也是支持动态扩容的,这不能成为你select具有FD数量上限的借口
select适用场景
尽管 select
有缺陷,但在以下场景中仍有一定价值:
- 对性能要求不高的场景:监控的 FD 数量较少(如不超过 100 个),对性能要求不高,这种简单场景下就用select,你也省事他也省事
- 硬件性能自身就比较低的场景:有些场景下,硬件自身性能就比较低,它就仅支持select,不支持poll和epoll,这时候你就只能用select
- 跨平台兼容性:
select
是 POSIX 标准接口,在所有 Unix/Linux、macOS、Windows(通过 WSL 或 Cygwin)系统中均支持,而epoll
仅支持 Linux。 - 教学与调试:逻辑简单易懂,适合作为 I/O 多路复用的入门学习案例。
总结
select
是 I/O 多路复用的“鼻祖”,通过内核统一等待多个 FD 就绪,解决了传统阻塞 I/O 的并发问题。但其 FD 数量上限、线性遍历等缺陷,使其仅适用于小规模并发场景。在现代高并发系统中,epoll
(Linux)或 kqueue
(BSD)已成为主流,但理解 select
的原理和使用方式,是掌握 I/O 模型的重要基础。