多路转接select(2)
多路转接select(2)
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 的第一个参数
select 缺点
• 每次调用 select, 都需要手动设置 fd 集合, 从接口使用角度来说也非常不便.
• 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
• 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大
• select 支持的文件描述符数量太小
1. 头文件包含和命名空间
#pragma once // 防止头文件重复包含#include <iostream> // 输入输出流
#include <memory> // 智能指针
#include <unistd.h> // UNIX标准函数,如close()#include "Socket.hpp" // 自定义Socket类头文件using namespace SocketModule; // 使用SocketModule命名空间
2. 类定义和常量
class SelectServer
{const static int size = sizeof(fd_set)*8; // fd_set的位数,通常是1024const static int defaultfd = -1; // 默认无效的文件描述符值
详细解释:
size:计算fd_set的位数,sizeof(fd_set)*8是因为1字节=8位,这是select能监控的最大文件描述符数defaultfd = -1:用-1表示数组槽位未被使用
3. 构造函数
public:SelectServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false){_listensock->BuildTcpSocketMethod(port); // 创建并绑定监听socketfor(int i=0; i<size; i++)_fd_array[i] = defaultfd; // 初始化所有槽位为-1_fd_array[0] = _listensock->Fd(); // 监听socket放在数组第一个位置}
执行流程:
- 创建TcpSocket智能指针对象
- 构建TCP socket并绑定指定端口
- 初始化fd数组,全部设为-1
- 将监听socket的描述符放入第一个位置
4. 主循环 Start() 方法
void Start()
{_isrunning = true;while (_isrunning){fd_set rfds; // 读文件描述符集合FD_ZERO(&rfds); // 清空集合int maxfd = defaultfd + 1; // 初始化为0// 遍历fd数组,设置需要监控的fdfor(int i=0; i<size; i++){if(_fd_array[i] == defaultfd)continue; // 跳过无效fdFD_SET(_fd_array[i], &rfds); // 将有效fd加入监控集合if(maxfd < _fd_array[i]){maxfd = _fd_array[i]; // 更新最大fd值}}PrintFd(); // 打印当前监控的fd状态// 调用select等待事件发生(无限等待)int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
select函数参数详解:
maxfd + 1:最大文件描述符+1(select要求)&rfds:读文件描述符集合(监控可读事件)nullptr:写文件描述符集合(不监控写事件)nullptr:异常文件描述符集合(不监控异常)nullptr:超时时间(null表示无限等待)
5. select返回值处理
switch (n){case -1:LOG(LogLevel::ERROR) << "select error"; // 选择错误break;case 0:LOG(LogLevel::INFO) << "time out..."; // 超时(本例中不会发生)break;default:// 有事件就绪LOG(LogLevel::DEBUG) << "有事件就绪了..., n : " << n;Dispatcher(rfds); // 分发处理就绪事件break;}}_isrunning = false;
}
n的含义:返回就绪的文件描述符总数
6. 事件分发器 Dispatcher
void Dispatcher(fd_set &rfds)
{for(int i=0; i<size; i++){if(_fd_array[i] == defaultfd)continue; // 跳过无效fd// 检查该fd是否在就绪集合中if(FD_ISSET(_fd_array[i], &rfds)){if(_fd_array[i] == _listensock->Fd()){Accepter(); // 监听socket就绪:接受新连接}else{Recver(_fd_array[i], i); // 客户端socket就绪:接收数据}}}
}
FD_ISSET宏:检查指定fd是否在就绪集合中
7. 接受新连接 Accepter
void Accepter()
{InetAddr client; // 客户端地址信息// 接受新连接(非阻塞,因为select已确认有连接待接受)int sockfd = _listensock->Accept(&client);if(sockfd >= 0) // 接受成功{LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client is: " << client.StringAddr();// 在数组中寻找空闲位置int pos = 0;for(; pos < size; pos++){if(_fd_array[pos] == defaultfd)break;}if(pos == size) // 数组已满{LOG(LogLevel::WARNING) << "Sever select full";close(sockfd); // 关闭新连接}else{_fd_array[pos] = sockfd; // 将新连接加入监控数组}}
}
8. 接收数据 Recver
void Recver(int fd, int pos)
{char buffer[1024];// 接收数据(非阻塞,因为select已确认有数据可读)ssize_t n = recv(fd, buffer, sizeof(buffer)-1, 0);if(n > 0) // 接收到数据{buffer[n] = 0; // 添加字符串结束符std::cout << "client says@" << buffer << std::endl;}else if(n == 0) // 客户端关闭连接{LOG(LogLevel::INFO) << "client quit..";_fd_array[pos] = defaultfd; // 从监控数组中移除close(fd); // 关闭socket}else // 接收错误{LOG(LogLevel::ERROR) << "recv error";_fd_array[pos] = defaultfd; // 从监控数组中移除close(fd); // 关闭socket}
}
9. 辅助方法
void PrintFd() // 打印当前监控的fd数组状态
{std::cout << "fd_array[]: ";for(int i=0; i<size; i++){std::cout << _fd_array[i] << " ";}std::cout << "\r\n";
}void Stop() // 停止服务器
{_isrunning = false;
}
工作流程总结:
- 初始化:创建监听socket,初始化fd数组
- 监控循环:将有效fd加入select监控集合
- 事件等待:select阻塞等待事件发生
- 事件分发:遍历检查哪个fd就绪
- 连接处理:监听socket就绪→接受新连接
- 数据处理:客户端socket就绪→接收数据
- 连接清理:客户端断开→从数组中移除并关闭socket
这种设计实现了单线程同时处理多个连接,是典型的多路复用IO模型。
宏定义之所以会有全局污染,是因为它在预处理器阶段进行简单的文本替换,没有作用域的概念。我来详细解释:
1. 宏定义的工作方式
#define SIZE 1024 // 在文件开头定义
#define MAX_BUFFER 8192class SelectServer {int _fd_array[SIZE]; // 预处理器替换为: int _fd_array[1024];
};class AnotherClass {char buffer[MAX_BUFFER]; // 替换为: char buffer[8192];
};
问题:宏在包含头文件后,对整个编译单元的所有代码都可见。
2. 全局污染的具体表现
示例1:命名冲突
// network.h
#define SIZE 1024
#define TIMEOUT 5000// graphic.h
#define SIZE 2048 // ❌ 重定义警告,或者 silently 替换
#define TIMEOUT 100 // ❌ 同一个宏名,不同含义!// main.cpp
#include "network.h"
#include "graphic.h" // 💥 冲突!int main() {int buffer[SIZE]; // SIZE 到底是 1024 还是 2048?取决于头文件顺序!
}
示例2:意外的文本替换
#define MAX 100class MathUtils {
public:int getMax(int a, int b) {return a > b ? a : b;}
};int main() {MathUtils utils;int result = utils.getMax(5, 10); // 看起来没问题
}// 但如果有这样的代码:
int MAXimum = 200; // 预处理器会替换为: int 100imum = 200; 💥
3. 实际项目中的污染问题
大型项目中的宏冲突:
// 第三方库A的定义
#define DEBUG 1
#define ERROR -1
#define SUCCESS 0// 第三方库B的定义
#define DEBUG 0 // ❌ 冲突!
#define ERROR 1 // ❌ 冲突!
#define SUCCESS 1 // ❌ 冲突!// 自己的代码
#include "libA.h"
#include "libB.h" // 💥 编译错误或不可预测行为
4. 对比 const static 的作用域控制
使用宏(有污染):
#define NETWORK_SIZE 1024
#define NETWORK_TIMEOUT 5000// 这些宏在包含此头文件的所有地方都可见
// 可能与其他头文件的宏冲突
使用 const static(无污染):
class NetworkConfig {
private:static constexpr int size = 1024;static constexpr int timeout = 5000;
public:static int getSize() { return size; }
};class GraphicConfig {
private: static constexpr int size = 2048; // ✅ 不会冲突,因为作用域不同static constexpr int timeout = 100;
};
5. 更糟糕的宏函数污染
#define MAX(a, b) ((a) > (b) ? (a) : (b))int main() {int x = 5, y = 10;int result = MAX(x, y); // 正常工作// 但这样的代码会有问题:int value = MAX(++x, y); // 展开为: ((++x) > (y) ? (++x) : (y))// x 可能被递增两次!💥
}// 如果有人定义了同名的函数:
int MAX(int a, int b) { // ❌ 可能与宏冲突return std::max(a, b);
}
6. 调试和维护困难
宏定义的调试问题:
#define CALCULATE(x, y) ((x) * (y) + (x) / (y))int result = CALCULATE(a + b, c - d);
// 调试时看到的是:result = ((a + b) * (c - d) + (a + b) / (c - d));
// 很难单步跟踪宏的展开过程
对比内联函数:
inline int calculate(int x, int y) {return x * y + x / y;
}
// 可以设置断点,可以单步调试,有类型检查
7. 现代C++的解决方案
使用命名空间 + constexpr:
namespace Network {constexpr int Size = 1024;constexpr int Timeout = 5000;
}namespace Graphic {constexpr int Size = 2048; // ✅ 不会冲突constexpr int Timeout = 100;
}// 使用时有明确的作用域:
int buffer1[Network::Size]; // 1024
int buffer2[Graphic::Size]; // 2048
8. 总结宏定义的污染问题
- 无作用域:宏在包含后对整个文件可见
- 命名冲突:不同库可能定义相同名称的宏
- 文本替换风险:可能意外替换不该替换的文本
- 调试困难:预处理器替换后难以跟踪原始代码
- 无类型检查:缺乏编译器的类型安全保护
- 难以维护:宏定义可能分散在多个头文件中
这就是为什么现代C++推荐使用 constexpr、enum class、namespace等特性来替代宏定义,它们提供了更好的作用域控制和类型安全。
1. select 函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
关键参数:nfds- 表示要检查的文件描述符范围
2. 为什么是 maxfd + 1
select 的工作方式:
// select 内部实际上是这样工作的:
for (int fd = 0; fd < nfds; fd++) {if (FD_ISSET(fd, readfds)) {// 检查fd是否可读}
}
重要概念:nfds不是文件描述符的个数,而是要检查的最大文件描述符值+1
示例说明:
int fds[] = {3, 5, 8, 12}; // 要监控的fd
int maxfd = 12; // 最大的fd值是12// 正确的调用:
select(13, &rfds, NULL, NULL, NULL); // 检查0-12号fd// 如果错误地调用:
select(4, &rfds, NULL, NULL, NULL); // 只检查0-3号fd,会漏掉5,8,12!
select(12, &rfds, NULL, NULL, NULL); // 只检查0-11号fd,会漏掉12!
3. 底层原理:位图机制
fd_set 的实现:
// fd_set 实际上是一个位数组(bit array)
typedef struct {unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(long))];
} fd_set;// 设置fd的位
#define FD_SET(fd, set) ((set)->fds_bits[(fd) / (8 * sizeof(long))] |= (1UL << ((fd) % (8 * sizeof(long)))))
位图示例:
fd_set rfds 的位图:
位位置: 0 1 2 3 4 5 6 7 8 9 10 11 12
值: 0 0 0 1 0 1 0 0 1 0 0 0 1
对应fd: 3 5 8 12nfds = 13 表示检查位0到位12(共13个位)
4. 历史原因和设计哲学
类Unix系统的文件描述符分配:
// 文件描述符通常从0开始分配,但0,1,2被标准流占用
int stdin_fd = 0; // 标准输入
int stdout_fd = 1; // 标准输出
int stderr_fd = 2; // 标准错误// 新分配的socket fd从3开始
int listen_fd = socket(...); // 可能返回3
int client_fd = accept(...); // 可能返回4
select 的设计选择:
- 基于范围:检查从0到nfds-1的所有fd
- 而不是基于数量:不是检查前nfds个fd
- 兼容性:与早期系统设计保持一致
5. 常见错误和正确用法对比
错误用法:
// ❌ 错误1:直接使用fd数量
int fd_count = 4; // 有4个fd要监控
select(fd_count, &rfds, NULL, NULL, NULL); // 错误!// ❌ 错误2:使用maxfd而不是maxfd+1
int maxfd = 12;
select(maxfd, &rfds, NULL, NULL, NULL); // 只检查0-11,漏掉12!// ❌ 错误3:使用固定值
select(1024, &rfds, NULL, NULL, NULL); // 如果maxfd>1023,会漏检!
正确用法:
// ✅ 正确:maxfd + 1
int maxfd = -1;
fd_set rfds;
FD_ZERO(&rfds);// 设置要监控的fd,并找出最大值
for (int fd : fd_list) {FD_SET(fd, &rfds);if (fd > maxfd) maxfd = fd;
}// 关键:maxfd + 1
if (maxfd >= 0) {int n = select(maxfd + 1, &rfds, NULL, NULL, NULL);
}
6. 性能影响
nfds 大小的影响:
// 情况1:nfds 过大(性能浪费)
int maxfd = 3; // 实际只需要监控fd=3
select(1024, &rfds, NULL, NULL, NULL); // 检查0-1023,浪费!// 情况2:nfds 过小(漏检)
int maxfd = 100; // 需要监控到fd=100
select(50, &rfds, NULL, NULL, NULL); // 只检查0-49,漏掉50-100!// 情况3:nfds 合适(最优)
int maxfd = 100;
select(101, &rfds, NULL, NULL, NULL); // 精确检查0-100
7. 与其他多路复用API的对比
poll 的不同设计:
// poll 使用数组,不需要+1
struct pollfd fds[100];
int nfds = 100; // 直接是fd的数量
int ret = poll(fds, nfds, timeout); // 不需要+1
epoll 的不同设计:
// epoll 使用就绪列表,与fd数值无关
int epoll_fd = epoll_create1(0);
int ret = epoll_wait(epoll_fd, events, maxevents, timeout);
8. 实际代码示例
class SelectServer {
private:std::vector<int> _client_fds;int _listen_fd;int FindMaxFd() {int maxfd = _listen_fd;for (int fd : _client_fds) {if (fd > maxfd) maxfd = fd;}return maxfd;}public:void EventLoop() {fd_set rfds;while (true) {FD_ZERO(&rfds);FD_SET(_listen_fd, &rfds);int maxfd = _listen_fd;for (int fd : _client_fds) {FD_SET(fd, &rfds);if (fd > maxfd) maxfd = fd;}// 关键代码:maxfd + 1int nready = select(maxfd + 1, &rfds, NULL, NULL, NULL);if (nready > 0) {// 处理就绪事件...}}}
};
总结
select(maxfd + 1, ...)的原因:
- 历史设计:select检查从0到nfds-1的所有文件描述符
- 位图机制:基于位数组的实现需要指定检查范围
- 包含性:
maxfd + 1确保最大fd被包含在检查范围内 - 性能优化:避免检查不必要的fd(0到maxfd之间的未使用fd)
这个设计虽然有些反直觉,但理解了背后的位图机制和历史原因后,就能明白为什么需要+1了。
您问到了关键点!我之前的解释可能不够准确。让我重新澄清这个问题:
1. select 的真实行为
实际上,select 不会"遗漏"fd,但会修改fd_set:
fd_set rfds;// 调用select之前:设置我们关心的所有fd
FD_ZERO(&rfds);
FD_SET(3, &rfds); // 监听socket
FD_SET(5, &rfds); // 客户端A
FD_SET(8, &rfds); // 客户端B
// rfds位图: ...0010 0101 (3,5,8位为1)// 第一次select调用
int n = select(9, &rfds, NULL, NULL, NULL);// 假设只有fd=5有数据可读
// select返回后,rfds位图变为: ...0010 0000 (只有5位为1)
// 3和8的位被清零了!但select确实检查了所有fd
关键理解:select检查所有我们设置的fd,但返回时只保留就绪的fd。
2. 为什么必须重新设置:第二次调用的问题
第二次循环时的问题:
// 第一次select调用后,rfds的状态:
// rfds = [5] ← 只有fd=5还被设置// 第二次循环,如果不重新设置:
int n = select(9, &rfds, NULL, NULL, NULL);
// 这次调用告诉内核:"我只关心fd=5的事件"
// 内核只会监控fd=5,完全忽略fd=3和fd=8!
3. 可视化整个过程
正确的流程:
循环1开始: rfds = [3,5,8] ← 告诉内核:监控3,5,8
select调用: 内核检查3,5,8,发现只有5就绪
循环1结束: rfds = [5] ← 内核返回:只有5就绪循环2开始: 重新设置rfds = [3,5,8] ← 再次告诉内核:监控3,5,8
select调用: 内核检查3,5,8,可能发现3和8就绪
循环2结束: rfds = [3,8] ← 内核返回:3和8就绪
错误的流程:
循环1开始: rfds = [3,5,8]
select调用: 内核检查3,5,8,发现只有5就绪
循环1结束: rfds = [5]循环2开始: 没有重新设置!rfds = [5] ← 错误:只告诉内核监控5
select调用: 内核只检查5,忽略3和8!
循环2结束: rfds = [5]或[] ← 可能漏掉3和8的重要事件!
4. 具体的遗漏场景
遗漏新连接请求:
// fd=3是监听socket,负责接受新连接
// 如果不重新设置fd=3,第二次select调用时:// 有新的客户端尝试连接...
// 但select调用:select(9, [5], NULL, NULL, NULL)
// 内核只监控fd=5,完全不知道要监控fd=3
// 结果:新连接请求被完全忽略!
遗漏客户端数据:
// fd=8是另一个客户端socket
// 客户端发送数据过来...
// 但select调用:select(9, [5], NULL, NULL, NULL)
// 内核只检查fd=5,不检查fd=8
// 结果:客户端数据积压在缓冲区,服务器不知情!
5. select 的API设计哲学
select的工作方式类似于"快照":
// 每次select调用都是一次独立的查询:
// "请检查当前这些fd的状态,然后告诉我哪些就绪"// 而不是epoll的"订阅"模式:
// "我长期关心这些fd,有事件时通知我"
代码对比:
// select的"快照"模式
fd_set snapshot;
制作快照(&snapshot); // 每次都要重新设置
问内核("当前这个快照里的fd状态如何?");// epoll的"订阅"模式
epoll_ctl("我长期关心这些fd"); // 一次性注册
epoll_wait("有事件时通知我"); // 无需重新注册
6. 实际测试代码验证
#include <iostream>
#include <sys/select.h>void test_select_behavior() {fd_set rfds;// 初始设置:监控fd 3,5,8FD_ZERO(&rfds);FD_SET(3, &rfds);FD_SET(5, &rfds); FD_SET(8, &rfds);std::cout << "调用select前: ";for (int i = 0; i < 10; i++) {if (FD_ISSET(i, &rfds)) std::cout << i << " ";}std::cout << std::endl;// 模拟select调用(这里用sleep代替实际select)// 实际select会修改rfds,这里手动模拟修改FD_ZERO(&rfds); // 模拟select清零FD_SET(5, &rfds); // 模拟只有fd=5就绪std::cout << "调用select后: ";for (int i = 0; i < 10; i++) {if (FD_ISSET(i, &rfds)) std::cout << i << " ";}std::cout << std::endl;// 如果不重新设置,下一次select调用就只监控fd=5!
}
7. 总结
正确的理解:
- select不会在检查时遗漏fd:第一次调用时,它确实检查了我们设置的所有fd(3,5,8)
- 问题出现在第二次调用:由于select修改了fd_set,如果不重新设置,第二次调用就变成了:“我只关心上次就绪的fd”
- 本质是API使用协议:select的协议要求每次调用前都要重新设置关心的fd集合
所以"遗漏"不是发生在第一次select调用中,而是发生在后续的调用中,因为我们错误地告诉内核只监控部分fd,而不是所有我们真正关心的fd。
