跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C++

IO 多路复用 select 接口解析与服务器实战

IO 多路复用是网络编程核心基础,select 作为经典实现支持单进程监控多连接。文章详解 select 接口参数及 fd_set 操作,通过封装 Socket 类构建服务器,展示初始化、事件分发及主循环流程。对比阻塞与非阻塞模型,分析 select 在文件描述符数量限制、用户态内核拷贝开销等方面的优缺点,为学习 poll 和 epoll 奠定基础。

静心发布于 2025/11/18更新于 2026/6/817 浏览
IO 多路复用 select 接口解析与服务器实战

前言

在网络编程领域,IO 模型是支撑高效通信的核心基础之一。当需要让单个进程或线程同时处理多个网络连接的 IO 事件时,'IO 多路复用(多路转接)' 技术成为了关键解法 —— 它能让程序通过少量进程 / 线程,高效监控并处理多个 IO 事件,极大提升系统对 IO 资源的利用效率。

select作为 IO 多路复用模型中经典且具有代表性的实现,是开发者接触'多路转接'的重要入门点。尽管随着技术演进,它逐渐显现出一些局限性,但深入理解 select的工作机制、使用逻辑及其优缺点,不仅能帮助我们掌握'单进程管理多连接'的核心思路,更是学习更先进多路复用技术(如 poll、epoll)的重要前提。

本文将围绕'select 实现多路转接'展开,从 select接口的基本定义入手,逐步讲解基于 select的多路转接服务器实现(包含套接字封装、初始化流程、fd_set对象操作及服务器主循环设计等),最后剖析 select自身的优势与不足。希望通过对这些内容的梳理,能让读者清晰把握 select在多路 IO 转接中的核心作用,为后续 IO 模型学习与网络编程实践筑牢基础。

在介绍 select这三种多路转接的 IO 模型之前,有必要先介绍以下 5 种 IO 模型分别是哪几种。

一。五种 IO 模型

我们在操作系统中直接调用,read && write将数据读取上来,其本质就是将数据从用户层拷贝到操作系统中/从操作系统中拷贝到用户层——就是'拷贝';

  • 虽然我们通过拷贝来发送/获取数据,但是我们必须要明确一个概念:IO = 等数据 + 拷贝,而不仅仅是对数据进行拷贝;
  • 对于写于要等发送缓冲区中有位置,对于读取要等接收缓冲区中有数据。

因此在进行拷贝之前,必须先判断条件是否成立,也就是读写事件是否就绪。

我们通常定义高效 IO 指的是:单位时间内,IO 过程中,等的比重越小,效率越高。

下面介绍五种 IO 模型:

  1. 阻塞性:直到'等待数据就绪'和'数据拷贝'两个阶段完全完成,IO 调用才返回;
  2. 非阻塞性:等待数据就绪阶段不阻塞(内核会立即返回结果),即若数据未就绪,内核会返回 EAGAIN或EWOULDBLOCK 错误;
  3. 信号驱动型:用一个线程监控多个 IO,避免进程在单个未就绪 IO 上阻塞;
  4. 多路复用/多路转接型:让内核在 IO 数据就绪时主动发送 SIGIO信号通知进程来拿取数据;
  5. 异步 IO 型:应用进程发起异步 IO 调用后,两个阶段(等待就绪、数据拷贝)均由内核完成,全程不阻塞进程。内核在完成所有操作后,通过'信号'或'回调函数'通知进程,进程直接使用已拷贝到用户缓冲区的数据。
  • 对于阻塞 IO 和非阻塞 IO 在效率上并没有什么区别,只不过非阻塞 IO 在不等待期间可以做其他事情,因此我们通常说它的效率更高一些。

下面介绍实现多路转接 IO 的 3 种方式。

二。select 实现多路转接

关于 select 实现多路转接,此处将分为两部分进行介绍:

  1. 介绍 select 的接口;
  2. 使用 select 实现一个简单的 ech 服务器。

2.1 select 接口

select可以一次等待多个文件,当有一个文件就绪了就返回,这样可以一次性等待多个文件,提高了等待的效率。

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

该接口就是 select的等待接口:

  1. 参数一 nfds:标识等待的文件描述符中最大的 + 1;

fd_set是内核提供的一种数据结构,其本质是一张位图,记录着要关心的文件描述符。 2. 参数二 readfds:是一个结构体,记录要关心读事件就绪的文件描述符; 3. 参数三 writefds:记录要关心写事件就绪的文件描述符; 4. 参数四 exceptfds:记录要关心异常事件的文件描述符;

struct timeval也是内核提供的一种结构体,用于记录 select要进行等待的事件:

struct timeval {
    time_t tv_sec; /* seconds */
    suseconds_t tv_usec; /* microseconds */
};

当时间到达/有文件读写事件就绪就会进行返回。

  1. 参数五 timeval:标识 select等待的时间,如果等待时间到了,还没有一个文件读写事件就绪 select也会进行返回,传 nullptr标识阻塞式的等待;
  2. 返回值:一个整形,标识就绪的文件描述符的个数。

上面的 fd_set是操作系统提供给我们的数据结构,我们不能直接对该数据结构进行操作,而应该使用操作系统提供的接口来进行操作:

  • void FD_ZERO(fd_set *set):将位图全部清零,用于初始化;
  • void FD_SET(int fd, fd_set *set):将 fd 文件描述符添加到位图中;
  • void FD_CLR(int fd, fd_set *set):将 fd 文件描述符从位图中移除;
  • void FD_ISSET(int fd, fd_set *set):检查 fd 文件描述符是否在位图中。

如果有文件描述符就绪,操作系统怎么告诉我们是那些文件就绪了???

为了让操作系统能够通知我们,select接口的后 4 个参数被设计为输入输出型参数。

  1. readfds输出来告诉,那些文件描述符的读事件已经就绪;
  2. writefds和 exceptfds也一样;
  3. timeval告诉我们,距离规定的返回时间还剩余多久。

select使用的是内核提供的现成的数据结构 fd_set,因此这也就意味着其可以监视的文件描述符的数量是有限的,可以通过 sizeof(fd_set)*8来计算出来。

2.2 select 服务器实现

为了方便理解,我们实现一个简单的服务器,将用户发送过来的数据在前面添加一个 server got a message后直接进行返回。

2.2.1 对网络套接字进行封装

首先我们先对网络套接字的接口进行封装:创建套接字,绑定,监听;关于这方面的知识可以查看之前的 TCP 相关内容,此时就直接贴实现方法:

const std::string defaultip_ = "0.0.0.0";
enum SockErr { SOCKET_Err, BIND_Err, };
class Sock {
public:
    Sock(uint16_t port) : port_(port), listensockfd_(-1) {}
    void Socket() {
        listensockfd_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listensockfd_ < 0) {
            Log(Fatal) << "socket fail";
            exit(SOCKET_Err);
        }
        Log(Info) << "socket success";
    }
    void Bind() {
        struct sockaddr_in server;
        server.sin_family = AF_INET;
        server.sin_port = htons(port_);
        inet_pton(AF_INET, defaultip_.c_str(), &server.sin_addr);
        if (bind(listensockfd_, (struct sockaddr*)&server, sizeof(server)) < 0) {
            Log(Fatal) << "bind fail";
            exit(BIND_Err);
        }
        Log(Info) << "bind success";
    }
    void Listen() {
        if (listen(listensockfd_, 10) < 0) {
            Log(Warning) << "listen fail";
        }
        Log(Info) << "listen success";
    }
    int Accept() {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int fd = accept(listensockfd_, (sockaddr*)&client, &len);
        if (fd < 0) {
            Log(Warning) << "accept fail";
        }
        return fd;
    }
    int Accept(std::string& ip, uint16_t& port) {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int fd = accept(listensockfd_, (sockaddr*)&client, &len);
        if (fd < 0) {
            Log(Warning) << "accept fail";
        }
        port = ntohs(client.sin_port);
        char bufferip[64];
        inet_ntop(AF_INET, &client.sin_addr, bufferip, sizeof(bufferip)-1);
        ip = bufferip;
        return fd;
    }
    int Get_fd() { return listensockfd_; }
    ~Sock() { close(listensockfd_); }
private:
    uint16_t port_;
    int listensockfd_;
};

下面就来实现 selectserver服务器:

2.2.2 构建出服务器类

首先就是构造出 Selectserver类来对服务器进行管理:

  1. 首先需要一个 Sock对象,进行 TCP 通信;
  2. 接着我们需要使用一个容器来存储所有要进行等待读写事件就绪的容器,此处为了简单我们直接使用一个数组来实现,该数组的大小就是 fd_set能够等待的文件个数;
  3. 此处我们假设 TCP 接收到的就是完整报文,因此就不设置 writefds的位图了,理论上是要进行设置的,大家可以自行实现以下;
const int fds_num_max = sizeof(fd_set) * 8;
const int defaultfd = -1;
class Selectserver {
public:
    Selectserver(uint16_t port) : _sock_ptr(new Sock(port)) {
        for (int i = 0; i < fds_num_max; i++) {
            _fds_array[i] = defaultfd;
        }
    }
private:
    std::shared_ptr<Sock> _sock_ptr;
    int _fds_array[fds_num_max]; // 该数组用来存储 select 要进行等待的文件描述符,初始值为 -1
};

下一步就是进行初始化:

2.2.3 进行初始化

初始化一共就分为 4 个步骤:

  1. 创建套接字;
  2. 进行绑定;
  3. 设置监听模式;
  4. 将套接字添加到 _fd_array数组中。

对于前三个步骤在前面我们已经进行封装过来,因此,此处可以直接进行调用。

  • 对于第四个步骤来说:我们在与客户端建立连接的时候,不知道什么时候客户端来进行连接,因此也需要进行等待,而这一等待工作本质上是在等待 Sock指向的套接字文件,因此也应该使用 select进行等待。

以下是具体实现:

void AddToArray(int fd) {
    int pos = 0;
    for (; pos < fds_num_max && _fds_array[pos] != defaultfd; pos++);
    if (pos == fds_num_max) {
        // select 已经到达监听极限了,不能再添加要进行监听的文件了
        // 1. 关闭文件
        // 2. 打印日志
        close(fd);
        Log(Warning) << "select is full";
    } else {
        // 1. 有位置直接进行添加
        _fds_array[pos] = fd;
        Log(Info) << "add a new fd : " << fd;
    }
}
void Init() {
    // 1. 创建套接字
    // 2. 绑定
    // 3. 设置监听
    // 4. 将套接字描述符加入到_fds_array 数组中
    _sock_ptr->Socket();
    _sock_ptr->Bind();
    _sock_ptr->Listen();
    AddToArray(_sock_ptr->Get_fd());
}
2.2.4 获取要进行等待的 fd_set 对象

我们此处设计的 select接口并不考虑 writefds和 exceptfds,因此我们只需要实现初始化传入的 readfds接口即可,我们需要有一个已经设置好了的 fd_set,以及一个其中最大的文件描述符,因此此处使用一个 pair作为返回值。

std::pair<fd_set, int> Get_readfds() {
    // 1. 对位图进行初始化
    // 2. 循环遍历_fds_array 数组,将要进行等待的文件描述符添加到位图中
    int max_num = 0;
    fd_set readfds;
    FD_ZERO(&readfds);
    for (int i = 0; i < fds_num_max; i++) {
        if (_fds_array[i] == -1) continue;
        FD_SET(_fds_array[i], &readfds);
        max_num = std::max(max_num, _fds_array[i]);
    }
    return std::make_pair(readfds, max_num);
}
2.2.5 对读写事件就绪的文件进行处理

当 select等待后,存在文件描述符就绪,就需要将这些文件描述符对应的数据拿上来。 而文件描述符又分为两种:

  1. 是 Sock 套接字文件描述符,要将已经建立好连接的文件描述符拿上来;
  2. 普通文件描述符,直接将输入缓冲区中的数据拿上来。
// 是套接字就绪
void Sockfd_Ready() {
    // 1. 将套接字中建立好的连接拿上来
    // 2. 将拿上来的文件描述符加入到_fds_array 中,等到客户端发送消息过来
    int fd = _sock_ptr->Accept();
    AddToArray(fd);
}
// fd 表示文件描述符,i 表示在数组中的位置
void Normalfd_Ready(int fd, int i) {
    // 1. 读取文件描述符中的数据
    // 2. 将数据简单处理后,进行返回(此处假设 TCP 接收的报文是完整的)
    char inbuffer[1024];
    int n = read(fd, inbuffer, sizeof(inbuffer)-1);
    if (n > 0) {
        inbuffer[n] = 0;
        std::string ret = "server got a message : ";
        ret += inbuffer;
        write(fd, ret.c_str(), ret.size());
    } else if (n == 0) {
        // 对方已经关闭文件
        // 1. 将在_fds_array 中的对应位置设为 -1 表示已经被移除了,不需要再进行等待
        // 2. 关闭文件描述符
        _fds_array[i] = defaultfd;
        close(fd);
    } else {
        // 出错了
        Log(Error) << "read fail";
    }
}
2.2.6 服务器主循环
  1. 进行 select 等待;
  2. 有文件描述符就绪,识别对应的文件描述符,将任务进行派发,看交给哪一个函数进行完成。
void Dispather(fd_set* fdreads) {
    int listensock = _sock_ptr->Get_fd();
    for (int i = 0; i < fds_num_max; i++) {
        if (_fds_array[i] == defaultfd || !FD_ISSET(_fds_array[i], fdreads)) continue;
        if (_fds_array[i] == listensock) {
            Sockfd_Ready();
        } else {
            Normalfd_Ready(_fds_array[i], i);
        }
    }
}
void Run() {
    while (true) {
        auto [fdreads, max_num] = Get_readfds();
        int n = select(max_num + 1, &fdreads, nullptr, nullptr, nullptr);
        if (n > 0) {
            // 有事件就绪,进行任务的派发
            Dispather(&fdreads);
        } else if (n == 0) {
            Log(Info) << "no file";
        } else {
            Log(Error) << "select fail";
        }
    }
}

以上就是整个 selectserver类的实现了。

三。select 的优缺点

优点:

  1. 所有的等待交给 select来做,只要有读事件就绪就通知上层来将数据取走;
  2. 多路转接,在单进程的情况下能够处理多个用户的请求;

缺点:

  1. 使用的是内核提供的数据结构 fd_set,等待的文件描述符的数量是有限的;
  2. 输入输出型参数使用起来麻烦,并且每次进行 select的时候都要进行重新设置;
  3. 要将 fd_set从用户层拷贝到内核中,又要拷贝回来,拷贝数据频繁;
  4. 使用第三方数组对用户的 fd 进行管理,用户称需要进行多次遍历,内核在进行检测的时候也要进行多次遍历。

后续文章中我们将讲解 select的替代方案:poll和 epoll.

目录

  1. 前言
  2. 一。五种 IO 模型
  3. 二。select 实现多路转接
  4. 2.1 select 接口
  5. 2.2 select 服务器实现
  6. 2.2.1 对网络套接字进行封装
  7. 2.2.2 构建出服务器类
  8. 2.2.3 进行初始化
  9. 2.2.4 获取要进行等待的 fd_set 对象
  10. 2.2.5 对读写事件就绪的文件进行处理
  11. 2.2.6 服务器主循环
  12. 三。select 的优缺点
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • 基于 DeepSeek 与 Neo4j 知识图谱的企业级 RAG 架构演进
  • Transformer 原理详解与 PyTorch 编码实现
  • Flutter Web 混合开发最佳实践
  • DEIM 实时目标检测算法及 Visdrone2019 数据集实战
  • AIGC 时代如何利用 DeepSeek 辅助孩子学习编程
  • Cursor 编辑器 C/C++ 代码跳转问题解决方案
  • Android 插件化开发:如何在插件中加载和使用 R 资源
  • VISSIM 与 Web 实时交互技术实现
  • OpenClaw 高级配置与云端本地协同实战
  • Visual Studio 17.14 GitHub Copilot 模型管理与自定义接入
  • AI 辅助撰写高质量文献综述:操作步骤与提示词指南
  • llama.cpp 核心特性、技术原理及部署实践
  • Flutter 跨平台 Web 认证插件 flutter_web_auth_2 适配 OpenHarmony 详解
  • 适合新手的 8 个 Python 机器学习项目
  • 本地 AI 服务远程访问难题与加密隧道方案
  • Java 房屋租赁系统的设计与实现
  • Llama-3.2-3B 部署指南:使用 Ollama 快速运行本地大模型
  • MiniMax 海螺 AI:图片与文本生成高质量视频实战
  • DeepSeek-R1 大模型基于 MS-Swift 框架的部署与微调实战
  • Podman 与 Docker 深度对比及实战指南

相关免费在线工具

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

  • JSON 压缩

    通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online

  • JSON美化和格式化

    将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online