初识select

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

  • select系统调⽤是⽤来让我们的程序监视多个⽂件描述符的状态变化的;
  • 程序会停在select这⾥等待,直到被监视的⽂件描述符有⼀个或多个发⽣了状态改变;

select函数原型

select的函数原型如下

#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的接⼝,来⽐较⽅便的操作位图.

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核⼼内存不⾜

理解 select 的执行过程:以 fd_set 为例

理解 select 模型的关键在于理解 fd_set 结构。为了便于说明,我们假设 fd_set 的长度为 1 字节(即 8 个 bit),每个 bit 对应一个文件描述符(fd)。这样,最多可以监控 8 个 fd。

  1. 定义并清空集合
fd_set set;
FD_ZERO(&set);

此时 set 的二进制表示为:0000 0000

  1. 加入 fd = 5
FD_SET(5, &set);

将第 5 位置为 1,set 变为:0001 0000(从低位 0 开始计数)

  1. 再加入 fd = 2 和 fd = 1
FD_SET(2, &set);
FD_SET(1, &set);

此时 set 变为:0001 0011

  1. 执行 select 阻塞等待
select(6, &set, NULL, NULL, NULL);
  • 第一个参数 6 表示监控的最大 fd 值 + 1(即监控 fd 0~5)。

  • select 会阻塞,直到 set 中关注的 fd 上有事件发生。

  1. 事件发生后的集合变化
    假设 fd = 1 和 fd = 2 上都发生了可读事件,则 select 返回。
    此时 set 被内核修改为:0000 0011

即只有 fd = 1 和 fd = 2 对应的位保持为 1,其他之前加入的 fd(如 fd = 5)被清空。

重要说明

  • select 返回后,fd_set 中只剩下那些真正发生了事件的 fd。

  • 因此,每次调用 select 之前,都需要重新用 FD_SET 添加需要监控的 fd(通常需要维护一个自己的数组来备份)。

这种“清空未活跃 fd”的行为是 select 的一大特点,也是它效率较低的原因之一(需要反复从用户态拷贝集合)。

select的特点

  1. 可监控的文件描述符数量
    select 能够监控的文件描述符个数取决于 fd_set 结构体的大小。sizeof(fd_set) 的值决定了位图的总位数(每个 bit 对应一个 fd)。例如,在某台服务器上 sizeof(fd_set) = 512 字节,那么最多可以监控 512 × 8 = 4096 个文件描述符。

不同平台下 fd_set 的大小可能不同,通常可以通过宏 FD_SETSIZE 查看上限。

  1. 需要额外的数组来保存待监控的 fd
    由于 select 调用返回后,fd_set 中只有发生事件的 fd 对应的位被保留,其余位会被清空,因此每次调用 select 之前都必须重新设置需要监控的 fd 集合。为此,通常的做法是:
  • 使用一个数组(例如 array)独立保存所有需要监控的 fd。

  • 每次调用 select 之前:
    先调用 FD_ZERO 清空 fd_set。
    遍历 array,用 FD_SET 将每个 fd 重新加入 fd_set。
    同时遍历过程中计算出最大 fd 值(maxfd),作为 select 的第一个参数(maxfd+1)。

  • select 返回后,再遍历 array,对每个 fd 使用 FD_ISSET 判断是否发生了事件,从而进行相应的读写操作。

select缺点

  1. 每次调用都需要手动设置 fd 集合
    由于 select 返回后会修改 fd_set(只保留发生事件的 fd),因此每次调用前都必须重新添加所有待监控的 fd。这既繁琐又容易出错,从接口易用性的角度来看非常不便。

  2. 用户态与内核态之间的频繁拷贝
    每次调用 select 都需要将 fd 集合从用户态拷贝到内核态。当监控的 fd 数量很大时,这一拷贝操作的开销不可忽视。

  3. 内核中需要线性遍历所有 fd
    select 在内核中实现时,会线性遍历传入的所有 fd,检查每个 fd 是否有事件发生。随着 fd 数量的增加,遍历开销呈线性增长,导致性能下降。

  4. 可监控的文件描述符数量太小
    select 默认能够监控的 fd 数量受 FD_SETSIZE 限制(通常为 1024)。虽然可以通过修改宏重新编译内核来增大,但既不灵活,也无法满足高并发场景的需求。

select使⽤⽰例 使⽤select实现字典服务器

  1. 启动:监听 socket 创建成功,fd_array[0] 存入监听 fd。

  2. 主循环:

  • 清空 rfds,根据 fd_array 中所有有效 fd 重新设置 rfds。

  • 调用 select 阻塞等待读事件。

  • 一旦有 fd 可读,select 返回。

  • EventDispatcher 遍历 fd_array,用 FD_ISSET 找出就绪的 fd。

  • 如果是监听 fd → Accepter 接收新连接,把新 client fd 加入 fd_array。

  • 如果是 client fd → Recver 读取请求,处理业务(此处为简单回显),若对方关闭则从 fd_array 移除。

  • 循环往复:每次循环都重新构建 fd_set,保证新增或移除的 fd 能立即被监控或取消监控。

#pragma once

#include <iostream>
#include <string>
#include <memory>
#include <sys/select.h>
#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"

// gsize = fd_set 的 bit 位数,即可监控的最大文件描述符数量
// sizeof(fd_set) 是字节数,乘以 8 得到 bit 数
const static int gsize = sizeof(fd_set) * 8;
const static int gdefaultfd = -1;

class SelectServer
{
public:
    // 构造函数:接收监听端口
    SelectServer(uint16_t port)
        : _listensock(std::make_unique<TcpSocket>())  // 创建 TcpSocket 对象,用基类指针管理
    {
        // 初始化监听套接字(socket, bind, listen)
        _listensock->BuildListenSocketMethod(port);
        // 初始化 fd_array 全部为 -1,表示空闲槽位
        for (int i = 0; i < gsize; i++)
        {
            fd_array[i] = gdefaultfd;
        }
        // 将监听套接字的 fd 放入数组下标 0,交给 select 统一监控
        fd_array[0] = _listensock->SockFd();
    }

    // 处理新连接:当监听套接字可读时调用
    void Accepter()
    {
        InetAddr clientaddr;
        // Accept 不会阻塞,因为 select 已经通知有连接就绪
        int sockfd = _listensock->Accept(&clientaddr);
        if (sockfd > 0)
        {
            // 找一个空闲槽位存放新连接的 fd
            int pos = 0;
            for (; pos < gsize; pos++)
            {
                if (fd_array[pos] == gdefaultfd)
                {
                    fd_array[pos] = sockfd;
                    break;
                }
            }
            // 如果数组满了,无法托管给 select,则直接关闭连接
            if (pos == gsize)
            {
                close(sockfd);
                LOG(LogLevel::WARNING) << "server is full, close new connection";
            }
        }
    }

    // 处理数据接收:当普通 socket 可读时调用
    void Recver(int index)
    {
        int sockfd = fd_array[index];
        char buffer[1024];
        // recv 不会阻塞,因为 select 已经通知有数据可读
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = '\0';
            std::cout << "client say@ " << buffer << std::endl;
            std::string echo_string = "server echo# ";
            echo_string += buffer;
            send(sockfd, echo_string.c_str(), echo_string.size(), 0);
        }
        else if (n == 0)
        {
            // 客户端关闭连接
            fd_array[index] = gdefaultfd; // 清除槽位
            close(sockfd);
        }
        else
        {
            // 出错
            fd_array[index] = gdefaultfd;
            close(sockfd);
        }
    }

    // 就绪事件分发:根据 rfds 找出具体就绪的 fd
    void EventDispatcher(fd_set &rfds)
    {
        for (int i = 0; i < gsize; i++)
        {
            if (fd_array[i] == gdefaultfd)
                continue;
            // 判断该 fd 是否在 rfds 集合中就绪
            if (FD_ISSET(fd_array[i], &rfds))
            {
                // 如果是监听套接字就绪,说明有新连接到来
                if (fd_array[i] == _listensock->SockFd())
                {
                    Accepter();  // 连接管理器
                }
                else
                {
                    Recver(i);   // IO 处理器
                }
            }
        }
    }

    // 主循环:运行 select 服务器
    void Run()
    {
        while (true)
        {
            // ------------------- select 准备工作 -------------------
            // 每次循环都需要重新构建 fd_set,因为 select 返回后集合会被内核修改
            fd_set rfds;
            FD_ZERO(&rfds);
            int maxfd = gdefaultfd;

            // 遍历 fd_array,将所有有效 fd 加入 rfds,同时计算最大 fd 值
            for (int i = 0; i < gsize; i++)
            {
                if (fd_array[i] == gdefaultfd)
                    continue;
                FD_SET(fd_array[i], &rfds);
                if (maxfd < fd_array[i])
                    maxfd = fd_array[i];
            }

            // ------------------- 调用 select -------------------
            // 参数1: maxfd+1 (监控的 fd 范围)
            // 参数2: 读集合
            // 参数3: 写集合 (nullptr 表示不关心)
            // 参数4: 异常集合 (nullptr)
            // 参数5: 超时时间 (nullptr 表示一直阻塞,直到有事件)
            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            // --------------------------------------------------

            switch (n)
            {
            case 0:
                // 超时(本程序 timeout = nullptr,永远不会超时)
                break;
            case -1:
                // select 调用出错
                LOG(LogLevel::ERROR) << "select error";
                break;
            default:
                // 有事件就绪,派发处理
                EventDispatcher(rfds);
                break;
            }
        }
    }

private:
    std::unique_ptr<Socket> _listensock; // 监听套接字(基类指针,实际指向 TcpSocket)
    int fd_array[gsize];                 // 辅助数组:存储所有需要监控的 fd
};

在这里插入图片描述
select 服务器的核心工作流

  1. 创建监听套接字(listen_fd),加入 select 的读集合。

  2. 调用 select 等待:

  • 如果 listen_fd 就绪 → 调用 accept,得到新的 client_fd(已连接套接字)。

  • 将这个 client_fd 也加入 select 的读集合(从此它也被监控)。

  1. 下次 select 返回时:
  • 如果是 listen_fd 就绪 → 有新连接,继续 accept。

  • 如果是某个 client_fd 就绪 → 调用 recv/read 读取数据,不会阻塞(因为已确认有数据)。

  1. 永不阻塞:因为 accept 只在有新连接时调用,recv 只在有数据时调用,不会在任何地方无故挂起。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐