Select多路转接

其实上一节我们已经写过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):清空所有位 → 全部置 0
  • FD_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就绪条件

读就绪
  1. 监听 socket:有新客户端连接(accept 不会阻塞)
  2. 已连接 socket:接收缓冲区有数据(recv 不会阻塞)
  3. 已连接 socket:对端关闭连接(recv 返回 0)
写就绪
  1. 发送缓冲区有空余空间(send 不会阻塞)
  2. 非阻塞 connect 成功 / 失败
异常就绪
  • 收到 TCP 带外数据(极少用)

6:select优缺点

优点
  • 跨平台:Windows/Linux/macOS 全支持(唯一跨平台多路复用 API)
缺点(4 个底层硬伤)
  1. fd 数量有上限:默认 4096,无法灵活扩容
  2. 用户态→内核态拷贝:每次调用都要拷贝整个位图集合,fd 多则开销大
  3. 内核 O (n) 遍历:每次都要遍历所有 fd,fd 越多越慢
  4. 参数重置麻烦 :输入输出参数合一,返回后必须重新设置位图

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里面写出重要学习的类体

https://github.com/silin-code/study-code/tree/2ad6f3914e22cf96a395a2e8dc8c15300f7c65f5/Project_TCPServer_Plus

相关推荐
aq55356002 小时前
开源吐槽大会:让技术痛点变笑点
c++·mfc
雨奔2 小时前
Kubernetes 网络策略(NetworkPolicy)完全指南:声明式 Pod 通信管控
网络·容器·kubernetes
t***5442 小时前
如何在 Dev-C++ 中切换编译器至 Clang
开发语言·c++
cen__y2 小时前
Linux04(重定向)
linux·服务器·c语言
王老师青少年编程2 小时前
csp信奥赛C++高频考点专项训练之贪心算法 --【区间贪心】:线段覆盖
c++·算法·贪心·csp·信奥赛·区间贪心·线段覆盖
CoderCodingNo2 小时前
【信奥业余科普】C++ 的奇妙之旅 | 14:程序的分叉路口——逻辑判断与 if-else 语句
开发语言·c++
The Chosen One9852 小时前
a进制转b进制的转换总结
开发语言·c++
senijusene3 小时前
I2C 总线框架下LM75A 温度传感器 Linux驱动开发:
linux·运维·驱动开发
wl85113 小时前
SAP CPI 教程003 如何抓取Http适配器异常信息
网络·网络协议·http