《Linux 网络编程》深入理解 IO 多路复用:select 函数详解与 Echo 服务实战

🔥小叶-duck:个人主页

❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux系统从入门到实践》《Linux网络从入门到实践》

《Qt 方寸极境》 《MySQL》

✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一、多路转接的本质:"等" 和 "拷贝" 分离](#一、多路转接的本质:“等” 和 “拷贝” 分离)

[1.1 IO 模型原理拆分](#1.1 IO 模型原理拆分)

[1.2 什么是 "条件就绪"](#1.2 什么是 “条件就绪”)

[1.4 常见多路复用方案对比](#1.4 常见多路复用方案对比)

[二、select 函数详解](#二、select 函数详解)

[2.1 函数原型](#2.1 函数原型)

[2.2 参数详细解析](#2.2 参数详细解析)

[2.2.1 nfds 参数](#2.2.1 nfds 参数)

[2.2.2 fd_set 与三类事件集合](#2.2.2 fd_set 与三类事件集合)

[2.2.3 fd_set 配套操作宏](#2.2.3 fd_set 配套操作宏)

[2.2.4 timeout 超时参数](#2.2.4 timeout 超时参数)

[2.3 返回值规则](#2.3 返回值规则)

[2.4 select 三种等待模式](#2.4 select 三种等待模式)

[2.4.1 永久阻塞模式](#2.4.1 永久阻塞模式)

[2.4.2 限时阻塞模式](#2.4.2 限时阻塞模式)

[2.4.3 非阻塞轮询模式](#2.4.3 非阻塞轮询模式)

[三、select 实战:从零实现 Echo 服务端开发](#三、select 实战:从零实现 Echo 服务端开发)

[5.1 基础组件回顾](#5.1 基础组件回顾)

[5.2 select 服务端核心类实现](#5.2 select 服务端核心类实现)

[5.3 主函数入口](#5.3 主函数入口)

[5.4 编译与运行](#5.4 编译与运行)

结束语


前言

在上一篇《Linux 网络编程》深入理解五种 IO 模型:从 IO 本质到非阻塞 IO 实战中,我们学习了 IO 的本质,逐一拆解了阻塞、非阻塞、信号驱动、IO 多路复用、异步 IO 五大模型,也通过 fcntl 完成了非阻塞 IO 的代码实践,分清了同步与异步 IO 的核心区别。

有了五种 IO 模型的理论基础,接下来我们就可以正式进入 IO 多路复用的详细学习。本篇将从多路转接的核心思想出发,讲解 select 函数的完整用法,包含函数原型、参数解析、等待模式、返回值规则,最后落地到代码实战,从零编写基于 select 的 Echo 服务端。

IO 多路复用是 Linux 高并发网络编程的核心基石,而 select 是我们接触的第一个多路复用接口。吃透 select 的原理、优缺点,也能为后续学习 poll、epoll 打下扎实的底层认知。

一、多路转接的本质:"等" 和 "拷贝" 分离

回顾前面学习的结论:任何 IO 操作都可以拆分为两大核心阶段,等待数据就绪 、内核与用户空间之间的数据拷贝。

传统read、recv这类系统调用,会将等待与拷贝两个步骤封装在一起 。线程如果不先对等待进行处理,调用后就会先阻塞等待 fd 条件就绪,就绪之后再执行数据拷贝。

多路转接(多路复用)的核心思想 ,就是将这两个步骤解耦拆分。

1.1 IO 模型原理拆分

  • 等 :交给select、poll、epoll这类专用函数。单个调用可以同时监听、等待多个文件描述符(fd),只负责检测 fd 是否满足就绪条件。
  • 拷贝 :仍然使用read、recv、write、send完成。只有当多路复用函数通知我们某个 fd 就绪之后,才去调用读写接口执行数据拷贝。这样当我们再调用读写函数的时候,就不会再先后进行等和拷贝两个过程了,而会直接进行拷贝。

多路转接本质上是:就绪事件通知机制。它只负责监测哪些 fd 已经满足读写条件,当事件就绪通知上层程序,由程序主动发起后续真正的数据拷贝操作。

可以用之前的钓鱼例子理解:一个钓鱼人同时持有多根鱼竿,每根鱼竿对应一个文件描述符。传统 IO 是只盯着一根鱼竿等待;多路转接可以一次性看管全部鱼竿,只要任意一根鱼竿有鱼上钩(事件就绪),就通知程序去处理。

1.2 什么是 "条件就绪"

select 等多路复用函数,核心工作就是等待 fd 的条件就绪 。就绪事件分为读就绪 、写就绪两大类:

  1. 读就绪:内核接收缓冲区存在可读取的数据,或是套接字收到新连接请求。
  2. 写就绪:内核发送缓冲区存在空闲空间,可以向内核写入数据。

当某个 fd 满足上面任意一类条件,select就会返回,通知应用程序该 fd 已经就绪,可以执行对应的 IO 操作。

补充知识点:新创建的 fd,默认发送缓冲区和接收缓冲区都为空,也就是说一开始默认没有内容可以读取,写的缓冲区全空可以写入数据。

因此读事件默认不就绪 ;写事件默认就是就绪状态。

1.4 常见多路复用方案对比

Linux 提供三种主流多路复用实现,核心思想一致,但底层数据结构、性能、使用限制差异很大。

方案 支持系统 底层数据结构 连接数上限 效率
select POSIX 兼容(Linux/Windows/macOS) 位图 固定上限,默认 1024 较低,内核需要线性遍历全部 fd
poll Linux/macOS 链表 无固定上限,受内存限制 中等,依旧需要线性遍历 fd 集合
epoll 仅 Linux 红黑树 + 就绪链表 无固定上限 高,仅返回活跃就绪 fd,无需遍历全部 fd

虽然 epoll 是三者中性能最优的方案,但select是基础、跨平台的实现,是我们学习多路转接的入门重点。

二、select 函数详解

select 是 Linux 最早实现的多路转接接口 ,属于事件驱动型系统调用。

它的核心能力就是在一次调用中,同时等待多个文件描述符,区分读、写、异常三类就绪事件。下面从函数原型、参数、配套宏、返回值、三种等待模式完整拆解。

2.1 函数原型

使用select需要引入头文件**<sys/select.h>**,函数原型:

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

这个函数看起来参数很多,但其实逻辑非常清晰。下面我们逐个拆解每一个参数的含义。

2.2 参数详细解析

2.2.1 nfds 参数

nfds 不是监听 fd 的总数量。取值规则:所有被监听 fd 里面的最大值 + 1 。

内核依靠这个值确定遍历范围 ,只需要扫描 0 到 nfds-1 的 fd,减少遍历开销。

示例:监听 fd=3、fd=5、fd=7,最大 fd 是 7,nfds设置为7+1=8。

2.2.2 fd_set 与三类事件集合

readfds、writefds、exceptfds 类型都是 fd_set ,分别代表读事件集合、写事件集合、异常事件集合 。

fd_set 底层是位图结构 ,每一个比特位对应一个 fd 编号;比特位为 1 代表监听该 fd 对应的事件。

_FD_SETSIZE 默认值为 1024 ,但不同的机器可能这个值不相同,这也就是说 select 最多只能同时监听 _FD_SETSIZE这么多 fd 的个数,这也是后面我们讲解select的缺点的其中之一。

重点: 这三个 fd_set 属于输入输出型参数

  • **输入阶段:**用户通过位图告诉内核,需要监听哪些 fd 的哪些事件。
  • 输出阶段: select 返回时,内核会修改位图,仅保留就绪 fd 对应的比特位,未就绪 fd 的 bit 会被清空 。 因此:每次调用 select 之前,必须重新初始化、填充 fd_set 集合。

如果同一个 fd 需要同时监听读、写事件,可以把 fd 同时加入读集合与写集合。内核只会反馈我们主动添加到集合里的 fd 事件。

2.2.3 fd_set 配套操作宏

fd_set 底层虽然是由结构体封装数组实现的位图,但我们不能直接手动位运算来修改比特位 。

不同操作系统、硬件平台下,底层数组的存储排布、字长存在差异,手写位运算代码会丧失跨平台兼容性。操作系统已经封装好 4 个标准宏,直接调用宏接口,就能安全、可移植地完成位图操作。

cpp 复制代码
// 清空fd_set中所有比特位,每次循环监听前一般要执行
void FD_ZERO(fd_set *set);
// 将指定fd加入集合,标记为需要监听
void FD_SET(int fd, fd_set *set);
// 将指定fd从集合移除,取消监听
void FD_CLR(int fd, fd_set *set);
// 检测fd对应的bit是否置1,用来判断fd是否就绪
int FD_ISSET(int fd, fd_set *set);
  • **FD_ZERO:**清空整个文件描述符集合,将所有比特位置 0。
  • **FD_SET:**把指定 fd 对应的比特位设置为 1,代表将该 fd 加入监听集合。
  • **FD_CLR:**把指定 fd 对应的比特位设置为 0,代表将该 fd 从监听集合移除。
  • **FD_ISSET:**检测 fd 对应的比特位,判断该 fd 是否处于就绪状态。

2.2.4 timeout 超时参数

timeout 是 struct timeval 结构体指针 ,用来设置 select 的等待时长,同样属于输入输出型参数。

cpp 复制代码
struct timeval {
    time_t tv_sec;      // 秒
    suseconds_t tv_usec;// 微秒
};

timeout 存在三种配置方式,对应三种等待模式:

  1. NULL(永久阻塞模式):select 持续阻塞,直到任意 fd 事件就绪才返回,没有超时。
  2. {0,0}(非阻塞轮询模式):不阻塞,立刻检测 fd 状态,检测完直接返回,CPU 占用高,不适合长时间使用。
  3. 设置固定时间(限时阻塞模式) :阻塞等待指定时长。如果期间事件就绪,函数返回,同时timeout会被内核修改,保存剩余等待时间;超时无事件则返回 0。

2.3 返回值规则

select返回值分为三种场景:

  • **返回 > 0:**成功,返回就绪文件描述符的总个数。
  • **返回 = 0:**等待超时,在 timeout 指定时间内,没有任何 fd 触发就绪事件。
  • 返回 < 0: 调用出错,错误码保存在errno。

**补充:**当 timeout 设置为 NULL 永久阻塞时,select 永远不会返回 0,返回 0 只会出现在设置了超时时间的场景。

2.4 select 三种等待模式

2.4.1 永久阻塞模式

timeout 传入 NULL。线程阻塞在 select 调用,直到被监听 fd 有事件就绪才返回,没有超时退出。适合持续后台监听的服务场景。

2.4.2 限时阻塞模式

给 timeval 设置固定时长 ,例如struct timeval timeout={5,0},代表最多阻塞等待 5 秒。

在等待时间内一旦事件就绪,select 立刻返回,同时更新 timeout 为剩余等待时长;超过时间没有事件就绪,则返回 0。该模式兼顾事件监听与定时任务,是工程最常用的模式。

2.4.3 非阻塞轮询模式

timeval 设置**{ 0, 0 }**。调用 select 不会阻塞线程,马上扫描全部 fd 集合,有就绪事件返回就绪数量,无就绪直接返回 0。会持续循环扫描 fd,CPU 消耗大,一般不推荐长期使用。

三、select 实战:从零实现 Echo 服务端开发

在上面我们学习了 select IO 多路复用的理论原理,现在我们可以实现一个基于select的 TCP 服务器。

本篇文章先完成框架搭建,实现监听套接字、接收新连接,后续再扩展客户端读写回声逻辑。

5.1 基础组件回顾

本次的实战依然会复用之前封装好的通用基础组件,组件的完整实现可以参考之前的博客文章。

  • **InetAddr.hpp:**封装网络地址转换,提供 IP、端口的转换与字符串打印功能
  • **Socket.hpp:**TCP 套接字封装,包含 socket 创建、bind 绑定、listen 监听、accept 获取连接等接口
  • **Logger.hpp:**线程安全日志模块,方便打印调试、错误、提示信息
  • **Mutex.hpp:**互斥锁与 RAII 锁守卫,用于多线程场景的资源保护

5.2 select 服务端核心类实现

我们封装SelectServer类作为服务主体,核心逻辑全部写在这个类中。

cpp 复制代码
#pragma once
#include <iostream>
#include <memory>
#include "Socket.hpp"

using namespace SocketModule;

class SelectServer
{
    const static int nums = sizeof(fd_set) * 8;
    const static int defaultfd = -1; // 表示当前位置没有连接fd占用

public:
    SelectServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false) // 派生类对象传给基类指针
    {
        _listensock->BuildTcpSocketMethod(port);

        // 初始化辅助数组fd_array(所有位置初始化为-1,表示所有位置没有连接fd占用)
        // 后续借助这个defaultfd我们就可以重复利用这些位置对连接fd进行存放
        for (int i = 0; i < nums; i++)
        {
            fd_array[i] = defaultfd;
        }
        fd_array[0] = _listensock->Fd();
    }

    ~SelectServer()
    {
    }

    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            // // InetAddr peer;
            // // auto res = _listensock->Accept(&peer); // 我们在select多路转接这里,我们可以直接进行accept吗?不行
            // // 因为:1istensockfd,也是一个fd,进程怎么知道1istenfd上面有新连接到来了呢?
            // // accept本质就是一个阻塞IO,accept真正关心的只是:传入的指定文件描述符listensockfd的读事件,
            // // 将listensockfd添加到select函数中,让select只帮我关心listensockfd读事件是否就绪!
            // // 并且将时间设置成阻塞(nullptr,结论:listensockfd的读事件就绪 == 新连接到来!

            // // 虽然我们知道fd_set所对应的数据类型就是由结构体封装的数组所表示的位图
            // // 但是我们并不能直接进行位操作把特定的比特位设置到这个位图中
            // // 因为我们所实现的位操作设置不一定具备跨平台性,为了保证任何系统都能完成设置操作
            // // 操作系统为我们提供了诸多宏操作,不需要我们自己进行位操作,调用对应的宏就可以完成比特位设置了
            // fd_set rfds;                // 定义rfds读文件描述符集
            // FD_ZERO(&rfds);             // 初始化rfds:进行清空
            // FD_SET(_listensock, &rfds); // 还是一样的问题:到这里我们设置到内核中了吗?没有
            // // 因为rfd我们是在局部函数中定义的,也就是说rfds变量是在用户栈中开辟的,并没有陷入内核

            // 关键问题:当select返回处理完就绪事件后,下一次再次调用select,
            // 你怎么清楚历史上有哪些fd是被你设置添加到rfds中,让我们的select继续关心??
            // 问题的产生原因:每次select返回的时候,rfds绝大多数情况都会被修改,
            // 修改成只剩"就绪的fd",当时还没有就绪的fd就会在位图中"丢失"

            // 所以,也就是说如果我们只依靠rfds来存放历史所有连接的文件描述符,是做不到的!
            // 所以,只靠rfds做不到,我们就需要额外借助一个辅助数组来帮我们完整记录服务器历史上所有获取到的连接文件描述符!
            fd_set rfds;
            FD_ZERO(&rfds);
            int maxfd = defaultfd; // 获取最大文件描述符的值,用于select
            // 1.每次select之前,都需要对rfds进行重置!
            for (int i = 0; i < nums; i++)
            {
                if (fd_array[i] == defaultfd)
                {
                    // 两种情况:1.当前位置还没有被占用;2.当前位置的连接被断开重新置成-1
                    // 不管是哪种情况,都不需要设置到位图中
                    continue;
                }
                FD_SET(fd_array[i], &rfds);
                // 2.最大文件描述符fd时刻是变化的(有旧的连接会断开也会出现新的连接)
                //  如果当前的fd_array[i]大于maxfd,则更新maxfd
                if (maxfd < fd_array[i])
                {
                    maxfd = fd_array[i];
                }
            }

            // int n = select(_listensock->Fd() + 1, &rfds, nullptr, nullptr, nullptr);
            Testprintfd();
            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            if (n == -1)
            {
                // 说明select失败
                LOG(LogLevel::ERROR) << "select error";
            }
            else if (n == 0)
            {
                // 说明超时了,如果最后一个参数为nullptr阻塞式select,则返回值不会为0
                LOG(LogLevel::INFO) << "time out...";
            }
            else
            {
                // 其他情况返回值则表示事件已经就绪的个数
                LOG(LogLevel::INFO) << "有事件就绪了... , n = " << n;
                // 如果是有一个新连接到来了那么select返回值就是1
                // 此时如果没有及时调用accept将连接从全连接队列取出,结果就是死循环打印信息。为什么?
                // 因为针对fd的类型是 监听fd,那么其读事件就绪的条件就是:全连接队列非空(有新连接完成握手)
                // 如果此时没有及时调用accept将连接从全连接队列取出,那么这个就绪条件就是恒成立的,select就会一直返回1进行打印信息

                HandlerEvent(); // 处理已经就绪的事件
            }
        }
        _isrunning = false;
    }

    void HandlerEvent()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client); // 此时accept还会阻塞吗?不会,等的操作已经在上面的select处理了
        if (sockfd >= 0)
        {
            LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client: " << client.StringAddress();
            // 此时我们就成功获取到了新连接,问题在于:我们此时能直接调用read/recv吗??不行!
            // 我们根本不清楚此时这个新的连接fd对应的读事件有没有就绪!
            // 如果我们直接调用read/recv,和阻塞IO没有任何区别!等和拷贝的过程就变成一起的了
            // 那我们如何清楚新的连接fd对应的读事件是否就绪呢?select
            // 所以我们需要将获取到的新连接fd存放到我们的辅助数组中进行记录

            int pos = 0;
            for (pos = 0; pos < nums; pos++)
            {
                if (fd_array[pos] == defaultfd)
                {
                    // 说明当前pos位置是空闲的,不管是之前就没有使用还是之前的fd断开连接,我们都可以使用这个位置
                    break;
                }
            }
            if (pos == nums)
            {
                // 说明整个辅助数组都被占满了,说明当前服务器已经达到了连接的上限不能再进行连接了,直接放弃连接请求
                LOG(LogLevel::WARNING) << "select server full...";
                close(sockfd);
            }
            else
            {
                fd_array[pos] = sockfd;
            }
        }
        else
        {
            LOG(LogLevel::WARNING) << "accept error..";
        }
    }

    void Testprintfd()
    {
        std::cout << "fd_array[]: ";
        for (int i = 0; i < nums; i++)
        {
            if (fd_array[i] == defaultfd)
                continue;
            std::cout << fd_array[i] << " ";
        }
        std::cout << "\r\n";
    }

private:
    std::unique_ptr<Socket> _listensock; // 监听套接字
    bool _isrunning;                     // 服务器状态

    int fd_array[nums]; // 构建一个用于记录服务器历史上所有连接的fd的辅助数组
                        // 这里我们直接使用定长数组即可,其他容器也可以,但是定长数组本身就可以约束服务器的连接数量
};

5.3 主函数入口

cpp 复制代码
#include "SelectServer.hpp"

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cout << "Usage: " << argv[0] << " port" << std::endl;
        exit(USAGE_ERR);
    }
    uint16_t port = std::stoi(argv[1]);

    // 这里使用了 C++11 引入的 std::unique_ptr 智能指针来在堆区创建服务器对象。
    // 采用智能指针的核心优势是 RAII(资源获取即初始化)机制:
    // 即使服务器在未来运行中抛出异常导致意外退出,unique_ptr 也会在出作用域时
    // 自动调用 selectServer 的析构函数,确保底层的文件描述符等系统资源被安全释放。
    std::unique_ptr<SelectServer> svr = std::make_unique<SelectServer>(port);

    // 调用 Start() 后,主线程的代码执行流将进入 selectServer 类内部的 while(true) 死循环。
    // 服务器从此刻起正式开始运转,持续不断地执行 "重置位图 -> select等待 -> 处理就绪事件" 的闭环逻辑。
    // 除非收到中断信号(如 Ctrl+C)或内部发生致命错误调用 exit(),否则程序将永远阻塞运行在此处。
    svr->Start();
    return 0;
}

核心代码解读:

  1. 辅助数组 fd_array 的作用: fd_array是 select 服务的核心。它完整保存全部需要监听的文件描述符,包含监听 socket 以及所有客户端连接 socket。 因为 select 调用后,fd_set会被内核修改,仅保留就绪 fd;未就绪的 fd 会从位图丢失。所以每次调用 select 之前,必须遍历辅助数组,重新把所有有效 fd 填充进 fd_set。
  2. **accept 为什么不会阻塞:**select 返回就绪事件,代表监听套接字上已经有新连接到达。内核的全连接队列中已经存放完成三次握手的连接,此时调用 accept 会立刻拿到连接,不会阻塞等待。
  3. 新连接的处理逻辑: 成功获取新连接sockfd之后,将这个 fd 存入fd_array空闲位置。下一轮循环构建 fd_set 的时候,这个新 fd 会被加入监听集合,select 就会监听该客户端 fd 上的读事件。

5.4 编译与运行

编译代码后,执行程序时传入端口号启动服务:

bash 复制代码
./select_server 8080

启动后服务监听 8080 端口,可以使用 telnet 或者 nc 工具发起 TCP 连接,测试服务接收新连接的能力。当前版本仅支持建立连接,后续章节继续扩展客户端数据读写,完成 Echo 回声功能。

结束语

到这里,我们完成了 select IO 多路复用的原理讲解与 Echo 服务端实战。我们从多路转接 "等待与拷贝分离" 的核心本质出发,完整拆解了 select 函数的参数、返回值与三种等待模式,并且动手实现了可运行的 select 服务端代码。

select 作为 IO 多路复用的经典实现,让我们直观感受到单线程同时监听多个文件描述符的能力,同时也能体会到它本身存在的一些性能短板。这些局限性,也正是 poll、epoll 诞生的原因。

下一篇我们将对本篇文章的 Echo 服务器继续优化补充,完成基于 select 的 Echo 服务器之后,我们会继续学习 poll,对比它和 select 的异同,并且进一步对我们 Echo 服务器修改成基于 poll 的实现。掌握好 select 的底层逻辑,会帮助我们更容易理解后续更高级的多路复用方案。

相关推荐
Tairitsu_H1 小时前
[Linux系统] 命令行参数 | 环境变量 | 程序地址空间
linux·环境变量·命令行参数·程序地址空间
雯宝1 小时前
devices_init / buses_init / classes_init / firmware_init / hypervisor_init 函数
linux
SDWAN_Cheap1 小时前
TCP滑动窗口与拥塞控制是什么?从流量控制到网络稳定性的完整解析
网络·tcp/ip·php
广东融科数据服务有限公司2 小时前
无网络仓储库房安防监控改造落地案例|离线闭环存储 + 4G 低功耗远程适配实战优化
网络·仓储改造
YumiProxy2 小时前
动态IP冲突排查实战:从ARP检测到DHCP地址池的完整链路
服务器·网络·网络协议·tcp/ip·ip
Dynadot_tech2 小时前
域名投资中的域名管理策略
网络·域名·域名注册·dynadot·域名管理
M78佐菲2 小时前
ARM学习笔记(12)
linux·arm开发·笔记·嵌入式硬件·学习
CHENKONG_CK2 小时前
RFID 赋能汽车零部件涂装产线,构建全流程数字化追溯体系
网络·人工智能·单片机·网络协议·tcp/ip·汽车
可涵不会debug3 小时前
C++ std::bind 实战教程:线程池、回调场景全覆盖
linux·开发语言·c++