其实上一节我们已经写过select的代码,但是很明显并没有深入学习,下面我会先介绍select和poll的纯理论知识,再结合我最近写的TCP服务器来介绍如何实际使用select。
1:IO多路复用的作用
1:传统TCP服务器的致命问题
我们写最基础的TCP服务器一定用过这两个逻辑:
accept():等客户端连接,阻塞recv():等客户端发数据,阻塞
这会导致两个死穴:
1:单线程只能处理一个连接:处理A客户端时,B客户端连不上,发不了数据
2:多线程/多进程太浪费:创建线程/进程耗内存,CPU切换成本高,高并发直接崩溃
2:IO多路复用的核心定义
一句话 :一个线程,同时监控多个文件描述符(socket) ,内核告诉我们「哪个 fd 就绪了」,我们再去处理,不就绪就不操作。
- 就绪:能读 / 能写 / 出异常
- 优势:单线程扛多连接,无阻塞浪费,无线程切换开销
2:select核心理论(Linux最早的多路复用API)
1:select的本质
系统调用 + 阻塞等待 + 事件通知
- 作用:监控一批 fd,阻塞直到至少一个 fd 就绪
- 返回:告诉用户「有多少个 fd 就绪了」,不告诉你具体是哪个(需要自己查)
2:select函数参数
cpp
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
1:nfds(最大文件描述符+1)
- 原理:fd 从 0 开始编号,内核要遍历
0 ~ max_fd所有 fd - 为什么 +1:告诉内核「我要监控到第 max_fd 个,遍历范围是 max_fd+1」
- 关键:必须每次重新计算,不能固定写死
2:readfds/writefds/exceptfd(三个fd集合)
- 类型:
fd_set(后面讲) - 核心属性:输入输出型参数
- 输入:你告诉内核「我要监控这些 fd」
- 输出:内核告诉你「这些 fd 就绪了」
分工:
- readfds:监控读就绪(最常用,accept/recv 都属于读)
- writefds:监控写就绪(send 用)
- exceptfds:监控异常(带外数据)
3:timeout(阻塞超时规则)
NULL:永久阻塞,直到有 fd 就绪0:不阻塞,立即返回(只检测状态)- 特定时间:超时返回 0,无就绪 fd
cpp
// POSIX标准的timeval结构体
struct timeval {
long tv_sec; // 成员1:秒数(seconds)
long tv_usec; // 成员2:微秒数(microseconds),取值 0 ~ 999999
};
3:fd_set核心理论(重点)
1:本质(位图)
- 不是普通数组,是按位存储的结构
- 每 1 位 → 对应 1 个文件描述符
- 位 = 1:监控该 fd
- 位 = 0:不监控 / 无事件
2:默认上限
sizeof(fd_set) = 512字节 -> 512*8=4096位
select最多监听4096哥fd(固定上限,改上限重要编译内核)
3:4个位图操作函数
FD_ZERO(&set):清空所有位 → 全部置 0FD_SET(fd, &set):把 fd 对应位置 1 → 加入监控FD_CLR(fd, &set):把 fd 对应位置 0 → 移出监控FD_ISSET(fd, &set):检测位是否为 1 → 判断该 fd 是否就绪
4:select完整执行流程
- 清空位图 :
FD_ZERO把所有位归 0 - 加入监控 :
FD_SET把监听 socket、客户端 socket 加入位图 - 调用 select :传入
max_fd+1、位图、超时,内核开始阻塞监控 - 内核遍历检测:内核循环检查所有 fd,找到就绪的就标记
- select 返回 :
- 未就绪的 fd:位图中对应位被清 0
- 就绪的 fd:位图中对应位保持 1
- 用户遍历判断 :用
FD_ISSET逐个查,找到就绪 fd 处理
5:select就绪条件
读就绪
- 监听 socket:有新客户端连接(accept 不会阻塞)
- 已连接 socket:接收缓冲区有数据(recv 不会阻塞)
- 已连接 socket:对端关闭连接(recv 返回 0)
写就绪
- 发送缓冲区有空余空间(send 不会阻塞)
- 非阻塞 connect 成功 / 失败
异常就绪
- 收到 TCP 带外数据(极少用)
6:select优缺点
优点
- 跨平台:Windows/Linux/macOS 全支持(唯一跨平台多路复用 API)
缺点(4 个底层硬伤)
- fd 数量有上限:默认 4096,无法灵活扩容
- 用户态→内核态拷贝:每次调用都要拷贝整个位图集合,fd 多则开销大
- 内核 O (n) 遍历:每次都要遍历所有 fd,fd 越多越慢
- 参数重置麻烦 :输入输出参数合一,返回后必须重新设置位图
3:select接入TCP小型项目
1:为了后续的项目,我们先写一个基类
cpp
#pragma once
//依赖
#include "../../config/Config.hpp"
#include "../log/Logger.hpp"
#include "../utils/Utils.hpp"
//前置声明Connection类
class Connection;
// ==============================================
// 抽象事件循环基类
// 功能:定义 select/poll/epoll 必须实现的统一接口
// 特点:不能直接创建对象,只能被继承
// ==============================================
class EventLoop {
public:
// 析构函数
virtual ~EventLoop() = default;
//三大核心接口
//1初始化:select/poll/epoll的初始化
virtual bool init() = 0;
///2添加客户端连接到监听集合
virtual bool addConnection(Connection* conn) = 0;
//3事件循环:不断监听事件,处理事件、
virtual void run() = 0;
};
2:select.hpp
cpp
#pragma once
//继承抽象事件循环基类
#include "EventLoop.hpp"
//连接管理类
#include "../connection/Connection.hpp"
//select头文件
#include <sys/select.h>
//STL容器存储连接
#include <vector>
#include <mutex>
#include <memory>
class SelectLoop : public EventLoop
{
public:
//重写三个核心接口
bool init() override;
bool addConnection(Connection* conn) override;
void run() override;
private:
//select核心:文件描述符集合
fd_set m_readFds;
/* fd_set是一个结构体,表示文件描述符集合
fd_set内容:一个整数数组,每个元素表示一个文件描述符是否在集合中
fd_set的操作:FD_ZERO清空集合,FD_SET添加文件描述符,FD_CLR删除文件描述符,FD_ISSET检查文件描述符是否在集合中*/
//记录当前最大的文件描述符值,select需要这个值来确定监听范围
int m_maxFd;
//存储所有客户端的连接
std::vector<Connection*> m_connections;
//用指针存储连接对象,方便动态管理连接生命周期
std::mutex mtx;
};
3:select.cpp
cpp
#include "SelectLoop.hpp"
//包含依赖路径
#include "../../config/Config.hpp"
#include "../log/Logger.hpp"
#include "../utils/Utils.hpp"
#include "../connection/Connection.hpp"
//初始化select事件循环
//功能:初始化SelectLoop对象,准备好监听文件描述符集合
bool SelectLoop::init() {
//1:使用FD_ZERO初始化文件描述符集合,清空集合
//作用:确保集合中没有任何文件描述符,准备好添加新的连接
FD_ZERO(&m_readFds);
//&m_readFds是fd_set类型的指针,传递给FD_ZERO函数以初始化文件描述符集合
//2:初始化最大文件描述符值为0,表示当前没有任何连接
//作用:在添加连接时会更新这个值,select需要知道监听的文件描述符范围
m_maxFd = 0;
//3:日志记录初始化成功
LOG_INFO("SelectLoop initialized successfully");
return true;
}
//添加连接到select事件循环
//函数功能:将新的客户端连接添加到SelectLoop的监听集合中,以便在事件循环中能够监听这个连接上的事件
//参数说明:
//conn:指向Connection对象的指针,表示要添加的客户端连接
//返回值:如果添加成功返回true,否则返回false
bool SelectLoop::addConnection(Connection* conn) {
//1合法性检查:判断连接指针是否为空,空指针直接返回
if(conn == nullptr) {
LOG_FATAL("Select addConnection failed: null connection pointer");
return false;
}
std::lock_guard<std::mutex> lock(mtx);
//2获取连接的文件描述符
int clientfd = conn->getFd();
//3使用FD_SET宏将连接的文件描述符添加到监听集合中
//作用:告诉select函数需要监听这个文件描述符上的事件
std::cout << clientfd << " "<< std::endl;
FD_SET(clientfd, &m_readFds);
//4更新最大文件描述符值,如果新连接的文件描述符大于当前最大值,则更新
if(clientfd > m_maxFd) {
m_maxFd = clientfd;
}
//5将连接对象加入vector容器
//作用:方便后续管理连接对象,比如在事件循环中处理事件时可以遍历这个容器
m_connections.push_back(conn);
//6日志记录添加连接成功
LOG_INFO("SelectLoop added connection fd=%d", clientfd);
return true;
}
//select事件循环
//功能:不断监听文件描述符集合中的事件,并处理这些事件
void SelectLoop::run() {
//1日志记录事件循环开始
LOG_INFO("SelectLoop event loop started %p",pthread_self());
//2事件循环:使用while(true)实现死循环,不断监听事件
while(true) {
/*核心注意
select调用会修改传入的文件描述符集合,
所以每次调用前需要重新设置监听集合,
这里我们使用一个临时变量tempFds来保存当前的监听集合,
每次调用select前都将m_readFds复制到tempFds中,
确保m_readFds保持不变,正确监听所有连接的事件
*/
fd_set tempFds = m_readFds;
int currentMaxfd;
std::vector<Connection*> tempConns;
//枷锁拷贝
{
std::lock_guard<std::mutex> lock(mtx);
tempFds=m_readFds;
currentMaxfd=m_maxFd;
tempConns = m_connections;
}
//select调用说明
//参数说明:
// 参数1:最大文件描述符 + 1(select规定的固定用法)
// 参数2:监听读事件的集合(我们只关心客户端发数据)
// 参数3:监听写事件的集合(nullptr-不监听)
// 参数4:监听异常事件的集合(nullptr-不监听)
// 参数5:超时时间(nullptr-永久阻塞,直到有事件触发)
// 返回值:就绪的文件描述符数量
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
int eventnum = select(m_maxFd + 1, //最大文件描述符值 + 1
&tempFds, //监听读事件的集合
nullptr, //不监听写事件
nullptr, //不监听异常事件
&tv);//永久阻塞,直到有事件触发
//std::cout << "select 函数返回就绪数量是: " << eventnum << std::endl;
//3:检查select调用结果
if(eventnum<=0) continue;
//4:遍历所有连接,检查哪些连接的文件描述符在就绪集合中
for(Connection* conn: m_connections)
{
if(conn==nullptr||conn->getStatus()==ConnStatus::CLOSED)
{
continue;
}
int currentfd = conn->getFd();
//判断fd是否有读事件触发
//FD_ISSET宏检查currentfd是否在tempFds集合中,如果在,说明这个连接有数据可读
if(FD_ISSET(currentfd, &tempFds))
{
//5:处理就绪事件
//调用连接对象的handleEvent方法,处理这个连接上的事件(比如读取数据、处理请求等)
conn->readData();
}
}
}
}
4:项目连接
因为项目文件有点多,很多类都封装了,我就只在blog里面写出重要学习的类体