12、多路转接 select

目录

[I/O 多路转接之 select](#I/O 多路转接之 select)

[初识 select](#初识 select)

[理解 select 执行过程](#理解 select 执行过程)

[socket 就绪条件](#socket 就绪条件)

[select 的特点](#select 的特点)

[select 缺点](#select 缺点)

[select 使用示例: 检测标准输入输出](#select 使用示例: 检测标准输入输出)

[select 使用示例](#select 使用示例)

tcp_select_server.hpp

dict_server.cc


I/O 多路转接之 select

初识 select

系统提供 select 函数来实现多路复用输入/输出模型.

• select 系统调用是用来让我们的程序监视多个文件描述符的状态变化的;

• 程序会停在 select 这里等待,直到被监视的文件描述符有一个或多个发生了状 态改变;

select 函数原型

cpp 复制代码
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set
*exceptfds, struct timeval *timeout);

参数解释:

• 参数 nfds 是需要监视的最大的文件描述符值+1;

• rdset,wrset,exset 分别对应于需要检测的可读文件描述符的集合,可写文件描 述符的集 合及异常文件描述符的集合;

• 参数 timeout 为结构 timeval,用来设置 select()的等待时间

参数 timeout 取值:

• NULL:则表示 select()没有 timeout,select 将一直被阻塞,直到某个文件 描述符上发生了事件;

• 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。

• 特定的时间值:如果在指定的时间段里没有事件发生,select 将超时返回

关于 fd_set 结构

其实这个结构就是一个整数数组, 更严格的说, 是一个 "位图". 使用位图中对应的位来表 示要监视的文件描述符. 提供了一组操作 fd_set 的接口, 来比较方便的操作位图.

cpp 复制代码
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组 set 中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组 set 中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组 set 中相关fd 的位
void FD_ZERO(fd_set *set); // 用来清除描述词组 set 的全部位

关于 timeval 结构

timeval 结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件 发生则函数返回,返回值为 0

函数返回值:

• 执行成功则返回文件描述词状态已改变的个数

• 如果返回 0 代表在描述词状态改变前已超过 timeout 时间,没有返回

• 当有错误发生时则返回-1,错误原因存于 errno,此时参数 readfds,writefds, exceptfds 和 timeout 的值变成不可预测

错误值可能为:

• EBADF 文件描述词为无效的或该文件已关闭

• EINTR 此调用被信号所中断

• EINVAL 参数 n 为负值。

• ENOMEM 核心内存不足

常见的程序片段如下:

cpp 复制代码
 fs_set readset;

FD_SET(fd,&readset);

select(fd+1,&readset,NULL,NULL,NULL);

if(FD_ISSET(fd,readset)){......}

理解 select 执行过程

理解 select 模型的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd。则 1 字节长的 fd_set 最大可以对应 8 个 fd.

• (1)执行 fd_set set; FD_ZERO(&set);则 set 用位表示是 0000,0000。

• (2)若 fd=5,执行 FD_SET(fd,&set);后 set 变为 0001,0000(第 5 位置为 1)

• (3)若再加入 fd=2,fd=1,则 set 变为 0001,0011

• (4)执行 select(6,&set,0,0,0)阻塞等待

• (5)若 fd=1,fd=2 上都发生可读事件,则 select 返回,此时 set 变为 0000,0011。注意:没有事件发生的 fd=5 被清空。

socket 就绪条件

读就绪

此时调用 read / recv / accept 等读操作,不会阻塞,会立即返回数据或结果。

• socket 内核中, 接收缓冲区中的字节数, 大于等于低水位标记 SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于 0;

• socket TCP 通信中, 对端关闭连接, 此时对该 socket 读, 则返回 0;

• 监听的 socket 上有新的连接请求,accept 可以立即返回。

• socket 上有未处理的错误,read 会返回 -1。

写就绪

此时调用 write / send 等写操作,不会阻塞,可以立即写入数据。

• socket 内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于 等于低水位标记 SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于 0;

• socket 的写操作被关闭(close 或者 shutdown). 对一个写操作被关闭的 socket 进行写操作, 会触发 SIGPIPE 信号;

• socket 使用非阻塞 connect 连接成功或失败之后;

• socket 上有未读取的错误

异常就绪

在 I/O 多路复用(select/poll/epoll)中,除了关注文件描述符是否可读可写 ,还可以关注是否发生异常事件。当出现这类特殊事件时,多路复用接口会标记该 fd 为"异常就绪"。

  • 收到带外数据(Out‑of‑Band Data, OOB)

    TCP 支持紧急数据(例如 Telnet 的 Ctrl+C),接收端可通过 MSG_OOB 标志读取。当有 OOB 数据到达时,selectexceptfdspollPOLLPRIepollEPOLLPRI 会被触发。

  • 某些错误条件(取决于实现)

    例如 socket 上发生了未处理的错误(如连接异常),部分系统也可能通过异常就绪通知。

接口 异常就绪对应字段/事件
select exceptfds 集合
poll revents 中的 POLLPRI
epoll events 中的 EPOLLPRI

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 支持的文件描述符数量太小.

select 使用示例: 检测标准输入输出

只检测标准输入:

使用 select 系统调用仅监视标准输入 (文件描述符 0)。当用户在终端输入数据(按回车)时,select 会返回,主程序再调用 read 读取并打印。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>

int main() {
    fd_set read_fds;          // 读文件描述符集合

    FD_ZERO(&read_fds);       // 清空集合
    FD_SET(0, &read_fds);     // 将标准输入(fd=0)加入集合

    for (;;) {
        printf("> ");         // 打印提示符
        fflush(stdout);       // 确保提示符立即显示

        // 第一个参数:最大fd+1 = 0+1 = 1
        // 第二个参数:读集合(监视可读)
        // 第三、四参数:写集合和异常集合(NULL表示不关心)
        // 第五参数:超时时间(NULL表示无限等待)
        int ret = select(1, &read_fds, NULL, NULL, NULL);

        if (ret < 0) {
            perror("select");
            continue;         // 出错后继续循环
        }

        // 检查标准输入是否在集合中且就绪
        if (FD_ISSET(0, &read_fds)) {
            char buf[1024] = {0};
            // 注意:演示代码未检查read返回值,实际应处理返回值<=0的情况
            read(0, buf, sizeof(buf) - 1);
            printf("input: %s", buf);
        } else {
            printf("error! invalid fd\n");
            continue;
        }

        // 重要:select会修改read_fds(只保留就绪的fd)
        // 所以下一次循环前必须重新初始化集合
        FD_ZERO(&read_fds);
        FD_SET(0, &read_fds);
    }

    return 0;
}

说明: • 当只检测文件描述符 0(标准输入)时,因为输入条件只有在你有输入信息的时候,才成立,所以如果一直不输入,就会产生超时信息。

select 使用示例

使用 select 实现了一个字典服务器 (即通过 TCP 查询单词含义)。用 Selector 类封装 select,管理多个客户端连接,TcpSelectServer 负责监听并派发请求。

  1. 监听 socket 被加入 Selector,监视其读就绪(有客户端连接)。

  2. Selector::Wait() 调用 select,返回所有读就绪的 socket。

  3. 遍历就绪列表:

    • 如果是监听 socket ,调用 Accept 接收新连接,并将新客户端 socket 加入 Selector

    • 如果是客户端 socket ,调用 Recv 读取请求,通过 Handler 业务函数生成响应,用 Send 返回结果。如果读取失败(客户端关闭),从 Selector 中删除并关闭 socket。

tcp_select_server.hpp

cpp 复制代码
#pragma once

#include <vector>
#include <unordered_map>
#include <functional>
#include <sys/select.h>
#include "tcp_socket.hpp"   // 假设已经封装了 TcpSocket 类(Socket/Bind/Listen/Accept/Recv/Send/Close等)

// 辅助函数:打印 fd_set 中哪些 fd 被设置(调试用)
inline void PrintFdSet(fd_set* fds, int max_fd) {
    printf("select fds: ");
    for (int i = 0; i <= max_fd; ++i) {          // 遍历 0~max_fd
        if (!FD_ISSET(i, fds)) continue;
        printf("%d ", i);
    }
    printf("\n");
}

// 业务处理函数的类型:接收请求字符串,填充响应字符串
typedef std::function<void(const std::string& req, std::string* resp)> Handler;

// Selector 类:封装 select 的操作,管理多个 TcpSocket 的读事件
// 注意:它存储 TcpSocket 对象的拷贝(需要 TcpSocket 可拷贝,通常用 shared_ptr 更好
class Selector {
public:
    Selector() {
        max_fd_ = 0;               // 当前最大文件描述符值
        FD_ZERO(&read_fds_);       // 清空读集合
    }

    // 添加一个 socket 到监视集合中
    bool Add(const TcpSocket& sock) {
        int fd = sock.GetFd();
        printf("[Selector::Add] %d\n", fd);
        if (fd_map_.find(fd) != fd_map_.end()) {
            printf("Add failed! fd has in Selector!\n");
            return false;
        }
        fd_map_[fd] = sock;        // 保存 fd -> TcpSocket 的映射
        FD_SET(fd, &read_fds_);    // 加入读集合
        if (fd > max_fd_) {
            max_fd_ = fd;          // 更新最大 fd
        }
        return true;
    }

    // 从监视集合中删除一个 socket
    bool Del(const TcpSocket& sock) {
        int fd = sock.GetFd();
        printf("[Selector::Del] %d\n", fd);
        if (fd_map_.find(fd) == fd_map_.end()) {
            printf("Del failed! fd has not in Selector!\n");
            return false;
        }
        fd_map_.erase(fd);          // 删除映射
        FD_CLR(fd, &read_fds_);     // 从读集合中清除

        // 重新计算最大 fd(因为被删除的 fd 可能是原来的 max_fd_)
        for (int i = max_fd_; i >= 0; --i) {
            if (FD_ISSET(i, &read_fds_)) {
                max_fd_ = i;
                break;
            }
        }
        return true;
    }

    // 等待事件发生,返回读就绪的 socket 列表
    bool Wait(std::vector<TcpSocket>* output) {
        output->clear();

        // 重要:select 会修改传入的 fd_set,所以必须传一个副本
        fd_set tmp = read_fds_;
        PrintFdSet(&tmp, max_fd_);               // 调试输出

        // 第一个参数:最大 fd + 1
        // 第二个参数:读集合(我们只关心可读)
        // 第三、四:写集合和异常集合(NULL)
        // 第五:超时时间(NULL 表示无限等待)
        int nfds = select(max_fd_ + 1, &tmp, NULL, NULL, NULL);
        if (nfds < 0) {
            perror("select");
            return false;
        }

        // 遍历所有可能的 fd(0 到 max_fd_),找出就绪的
        for (int i = 0; i <= max_fd_; ++i) {
            if (!FD_ISSET(i, &tmp)) continue;
            // 从映射表中取出对应的 TcpSocket 对象,加入输出列表
            output->push_back(fd_map_[i]);
        }
        return true;
    }

private:
    fd_set read_fds_;                        // 读文件描述符集合(主副本)
    int max_fd_;                             // 当前监视的最大 fd
    std::unordered_map<int, TcpSocket> fd_map_;  // fd -> TcpSocket 映射
};

// TcpSelectServer 类:使用 Selector 实现 TCP 服务器
class TcpSelectServer {
public:
    TcpSelectServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {}

    // 启动服务器,传入业务处理函数 handler
    bool Start(Handler handler) const {
        // 1. 创建监听 socket
        TcpSocket listen_sock;
        if (!listen_sock.Socket()) return false;

        // 2. 绑定地址和端口
        if (!listen_sock.Bind(ip_, port_)) return false;

        // 3. 开始监听(backlog = 5)
        if (!listen_sock.Listen(5)) return false;

        // 4. 创建 Selector 对象,并把监听 socket 加入监视
        Selector selector;
        selector.Add(listen_sock);

        // 5. 主事件循环
        for (;;) {
            std::vector<TcpSocket> ready_list;
            if (!selector.Wait(&ready_list)) {
                continue;   // select 失败,重试
            }

            // 6. 处理就绪的 socket
            for (size_t i = 0; i < ready_list.size(); ++i) {
                int fd = ready_list[i].GetFd();

                // 情况1:就绪的是监听 socket → 有新连接到来
                if (fd == listen_sock.GetFd()) {
                    TcpSocket new_sock;
                    // Accept 不会阻塞(因为 select 已告知可读)
                    if (!listen_sock.Accept(&new_sock, NULL, NULL)) {
                        continue;
                    }
                    // 将新客户端 socket 加入 Selector 监视
                    selector.Add(new_sock);
                }
                // 情况2:就绪的是客户端 socket → 有数据可读
                else {
                    std::string req, resp;
                    // 接收请求(注意:这里假设 Recv 能完整读取一次请求,实际要处理粘包)
                    bool ret = ready_list[i].Recv(&req);
                    if (!ret) {
                        // 对端关闭或出错,删除并关闭 socket
                        selector.Del(ready_list[i]);
                        ready_list[i].Close();
                        continue;
                    }

                    // 调用业务处理函数(例如查字典,生成响应)
                    handler(req, &resp);

                    // 发送响应给客户端
                    ready_list[i].Send(resp);
                }
            }
        }
        return true;
    }

private:
    std::string ip_;
    uint16_t port_;
};

dict_server.cc

cpp 复制代码
#include "tcp_select_server.hpp"
#include <iostream>

// 示例业务处理:单词->简单翻译
void DictHandler(const std::string& req, std::string* resp) {
    if (req == "hello") *resp = "你好";
    else if (req == "world") *resp = "世界";
    else *resp = "未知单词";
}

int main() {
    TcpSelectServer server("0.0.0.0", 9090);
    server.Start(DictHandler);
    return 0;
}

注意:

  • select 的 fd_set 会被修改 ,所以 Wait 中必须拷贝一份传给内核。

  • 最大 fd 值 需要动态维护(max_fd_),select 的第一个参数是 max_fd_ + 1

  • 两种 socket 类型 :监听 socket(只负责 accept)和客户端 socket(负责读写数据)。

  • 事件驱动select 告诉你有事件,你再根据 fd 区分是连接还是数据。

  • 限制fd_set 默认最大 fd 为 FD_SETSIZE(通常 1024),不适合高并发。

    实际更常用 poll / epoll,但 select 的思想是基础。

相关推荐
无足鸟ICT1 小时前
【RHCA+】编辑多个文件
linux
fengyehongWorld1 小时前
Linux fd命令
linux
AIMath~1 小时前
hermes agent安装在Linux centos中
linux·运维·服务器
赵民勇1 小时前
如何查看一个二进制程序是否设置了rpath或runpath?
linux·c++
小王师傅662 小时前
深入解析:Docker在Mac上的运行本质与Linux进程管理机制
linux·macos·docker
Irissgwe2 小时前
9、数据链路层
linux·网络·mac·ip·数据链路层·arp协议·以太网帧格式
lwprain2 小时前
Umi-ocr2.1.5的linux部署,仅做记录
linux·服务器·umi-ocr
鹏大师运维2 小时前
统信UOS安装Subtitle Edit并使用Edge-TTS生成AI语音教程
linux·前端·人工智能·edge·麒麟·统信uos·ai语音
syagain_zsx2 小时前
Linux进程全面解析:从基础到高级管理(1/3)
linux