云计算百科
云计算领域专业知识百科平台

【Linux网络】深入理解Linux IO多路复用:从本质到select服务器实战

在这里插入图片描述

🔥草莓熊Lotso:个人主页

❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》

✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:

在这里插入图片描述


文章目录

  • 前言
  • 一. 多路转接的本质:把 "等" 和 "拷贝" 分开
    • 1.1 什么是 "条件就绪"
    • 1.2 常见多路复用方案对比
  • 二. select 函数详解
    • 2.1 参数详解
    • 2.2 返回值
  • 三. socket 就绪条件详解
    • 3.1 读就绪
    • 3.2 写就绪
    • 3.3 异常就绪(选学)
  • 四. select 的编程特点与缺点
    • 4.1 select 的编程特点
    • 4.2 select 的缺点
  • 五. 实战:从零实现 select 服务器
    • 5.1 基础组件回顾
    • 5.2 select 服务器核心实现
    • 5.3 主函数
    • 5.4 代码解读
    • 5.5 编译运行
  • 结尾:

前言

在上一篇文章中,我们详细讲解了 Linux 下的五种 IO 模型,其中IO 多路复用是目前高并发服务器的基石。它用一个线程就能同时处理成百上千个连接,彻底解决了阻塞 IO"一个连接一个线程" 的性能瓶颈。而select,作为 Linux 系统上第一个实现 IO 多路复用的系统调用,虽然现在已经逐渐被更高效的epoll取代,但它是理解所有多路复用技术的基础。几乎所有操作系统都实现了select接口,掌握它不仅能帮你理解多路复用的核心思想,也是面试中绕不开的考点。本文将从最底层的多路转接本质讲起,带你彻底搞懂select函数的每一个参数,最后通过实战代码从零实现一个基于select的 TCP 服务器。

在这里插入图片描述


一. 多路转接的本质:把 “等” 和 “拷贝” 分开

在讲select之前,我们必须先回顾一个核心结论:任何 IO 操作都可以拆分为两个步骤:等 + 拷贝。

我们之前用的read、recv这些系统调用,其实是把这两件事一起做了:先等数据就绪,再把数据从内核拷贝到用户空间。而多路转接技术的核心思想,就是把这两步彻底分开:

  • 等:由select、poll、epoll这类专门的函数来做,一次可以同时等多个文件描述符(fd)
  • 拷贝:还是由原来的read、recv、write、send来做,只有当select告诉我们某个 fd 就绪了,我们才去调用这些函数

换句话说,多路转接就是一个就绪事件的通知机制。它告诉我们哪个 fd 已经准备好了,可以进行读写操作了,而我们只需要在收到通知后,去执行实际的拷贝操作即可。

1.1 什么是 “条件就绪”

select等多路复用函数,本质上就是在等待 fd 的 “条件就绪”。条件就绪分为两种:

  • 读就绪:内核接收缓冲区中有数据可读,或者有新的连接请求到来
  • 写就绪:内核发送缓冲区中有空闲空间,可以写入数据

当某个 fd 满足以上任意一个条件时,select就会返回,告诉我们这个 fd 已经就绪,可以进行对应的 IO 操作了。

在这里插入图片描述

1.2 常见多路复用方案对比

Linux 系统提供了三种主流的多路复用实现,它们的核心思想一致,但在性能和使用方式上有很大区别:

方案支持系统数据结构连接数上限效率
select POSIX 兼容(Linux/Windows/macOS) 位图 固定(默认 1024) 低,内核需线性遍历所有 fd
poll Linux/macOS 链表 无固定上限(受内存限制) 中,仍需线性遍历
epoll 仅 Linux 红黑树 + 就绪链表 无固定上限 高,仅通知活跃 fd,无需遍历

虽然epoll是目前性能最好的,但select作为最基础、最通用的实现,是我们必须掌握的。

在这里插入图片描述


二. select 函数详解

select函数的原型如下:

#include <sys/select.h>

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

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

2.1 参数详解

1. nfds:最大文件描述符 + 1

这是最容易被误解的一个参数。很多人以为它是要监听的 fd 的个数,其实不是。nfds 的值应该是你要监听的所有 fd 中,最大的那个 fd 的值再加 1。

比如你要监听 fd=3、fd=5、fd=7 这三个 fd,那么nfds应该设置为7+1=8。这样内核只需要遍历 0 到 7 这 8 个 fd,而不需要遍历整个 1024 位的位图,提高了效率。

2. readfds、writefds、exceptfds:三个文件描述符集

这三个参数分别对应我们要监听的读事件、写事件和异常事件的 fd 集合。它们的类型都是fd_set,本质上是一个位图结构。

fd_set结构体的定义大致如下:

typedef struct {
long int fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;

其中__FD_SETSIZE默认是 1024,所以select最多只能同时监听 1024 个 fd,这是它最致命的缺点之一。

为了方便操作这个位图,系统提供了四个宏:

// 清空fd_set中的所有位
void FD_ZERO(fd_set *set);
// 将fd添加到fd_set中
void FD_SET(int fd, fd_set *set);
// 将fd从fd_set中移除
void FD_CLR(int fd, fd_set *set);
// 测试fd是否在fd_set中被置位
int FD_ISSET(int fd, fd_set *set);

非常重要的一点:这三个参数都是输入输出型参数。

  • 输入时:用户通过这三个参数告诉内核,我们关心哪些 fd 上的哪些事件
  • 输出时:内核通过这三个参数告诉用户,哪些 fd 上的哪些事件已经就绪了

也就是说,每次调用select之后,这三个fd_set都会被内核修改。未就绪的 fd 对应的位会被清空,只有就绪的 fd 对应的位会被保留。因此,每次调用**select**之前,都必须重新设置这三个参数。

3. timeout:超时时间

这个参数用来设置select的等待时间,类型是struct timeval:

struct timeval {
time_t tv_sec; // 秒
suseconds_t tv_usec; // 微秒
};

timeout有三种取值:

  • NULL:select会永久阻塞,直到有 fd 就绪
  • {0, 0}:select会立即返回,不阻塞,这是非阻塞模式
  • 特定时间值:select会阻塞指定的时间,如果在这段时间内没有 fd 就绪,就返回 0

注意:当select因为有 fd 就绪而返回时,timeout结构体的值会被修改为剩余的时间。比如你设置了 5 秒超时,结果 2 秒后就有 fd 就绪了,那么返回时timeout.tv_sec会变成 3。

2.2 返回值

select的返回值有三种情况:

  • > 0:成功,返回就绪的 fd 的个数
  • = 0:超时,在指定时间内没有任何 fd 就绪
  • < 0:出错,错误码存放在errno中

在这里插入图片描述 在这里插入图片描述


三. socket 就绪条件详解

很多人写select代码时,不知道什么时候 fd 才算就绪。这里我们详细讲解一下 TCP socket 的就绪条件,这是正确使用select的前提。

3.1 读就绪

当满足以下任意一个条件时,socket 就处于读就绪状态:

  • 内核接收缓冲区中的字节数大于等于低水位标记SO_RCVLOWAT(默认是 1),此时可以无阻塞地读取数据,并且返回值大于 0
  • TCP 通信中,对端关闭了连接,此时对该 socket 读会返回 0
  • 监听的 socket 上有新的连接请求到来(这是服务器最核心的事件)
  • socket 上有未处理的错误
  • 3.2 写就绪

    当满足以下任意一个条件时,socket 就处于写就绪状态:

  • 内核发送缓冲区中的可用字节数大于等于低水位标记SO_SNDLOWAT(默认是 1),此时可以无阻塞地写入数据,并且返回值大于 0
  • socket 的写操作被关闭(调用了close或shutdown),对这样的 socket 写会触发SIGPIPE信号
  • 非阻塞connect连接成功或失败之后
  • socket 上有未读取的错误
  • 3.3 异常就绪(选学)

    当 socket 上收到带外数据(TCP 紧急数据)时,就处于异常就绪状态。这在实际开发中很少用到,了解即可。


    四. select 的编程特点与缺点

    4.1 select 的编程特点

    通过上面的讲解,我们可以总结出select编程的几个核心特点:

  • 需要辅助数组保存所有要监听的 fd:因为select返回后会清空未就绪的 fd,所以我们需要一个额外的数据结构(通常是数组)来保存所有要监听的 fd,每次调用select前,都要从这个数组中把 fd 重新添加到fd_set中
  • 每次调用前必须重置 fd_set:由于fd_set是输入输出型参数,每次调用后都会被内核修改,所以必须在每次循环开始时,重新调用FD_ZERO清空,然后从辅助数组中重新添加 fd
  • 需要手动计算最大 fd:每次添加或删除 fd 时,都要重新计算当前最大的 fd,作为nfds参数的值
  • 4.2 select 的缺点

    select虽然简单通用,但它的缺点也非常明显:

  • 支持的 fd 数量有限:默认最多只能监听 1024 个 fd,虽然可以通过重新编译内核来修改,但这会带来兼容性问题
  • 用户态与内核态拷贝开销大:每次调用select,都需要把整个fd_set从用户态拷贝到内核态,当 fd 很多时,这个开销会非常大
  • 内核需要线性遍历所有 fd:内核每次都要遍历所有传入的 fd,检查是否有事件就绪,当 fd 很多时,效率会急剧下降
  • 接口使用不便:每次调用前都要手动重置fd_set,计算最大 fd,非常繁琐

  • 五. 实战:从零实现 select 服务器

    讲完了理论,我们来动手实现一个基于select的 TCP 服务器。这个服务器目前只处理新连接的建立,后续我们会继续完善,添加客户端数据的读写功能。

    5.1 基础组件回顾

    我们会复用之前封装好的几个基础组件(想看具体实现的,可以翻翻我之前的博客,比如网络版本计算器这个里面好像就有):

    • InetAddr.hpp:封装了网络地址的转换
    • Socket.hpp:封装了 TCP socket 的创建、绑定、监听、接受连接等操作
    • Logger.hpp:线程安全的日志系统
    • Mutex.hpp:互斥锁和 RAII 锁守卫

    这里重点看一下Socket.hpp中新增的地址和端口复用设置:

    void CreateSocketOrDie() override
    {
    _sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if(_sockfd < 0)
    {
    LOG(LogLevel::FATAL) << "create socket error";
    exit(SOCKET_ERROR);
    }
    int opt = 1;
    // 设置地址复用
    setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    // 设置端口复用
    setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
    LOG(LogLevel::INFO) << "create socket success";
    }

    添加这两个选项可以解决服务器重启时 “地址已被占用” 的问题,是服务器开发中的最佳实践。

    5.2 select 服务器核心实现

    我们的selectServer类的核心代码如下:

    #ifndef __SELECTSERVER__HPP
    #define __SELECTSERVER__HPP

    #include "Logger.hpp"
    #include "InetAddr.hpp"
    #include "Socket.hpp"
    #include <bits/types/struct_timeval.h>
    #include <cstdint>
    #include <memory>
    #include <sys/select.h>

    // [核心解析]:sizeof(fd_set) 通常是 128 字节,128 * 8 = 1024。这是 select 底层位图能表示的最大比特位数。
    // 换句话说,这也直接锁死了我们这个服务器最多只能同时托管 1024 个文件描述符(包含监听套接字)。
    #define NUM (sizeof(fd_set) * 8)
    using namespace LogModule;
    const int gdefaultfd = 1;

    class selectServer
    {
    public:
    selectServer(uint16_t port)
    :_port(port)
    ,_listenfd(std::make_unique<TcpSocket>())
    {
    _listenfd->BulidSocketMethod(_port);
    // 初始化
    // [核心解析]:辅助数组的作用是在用户态(应用层)保存所有需要被 select 监控的 fd。
    // 初始时全部置为 -1 (gdefaultfd),表示该槽位目前为空闲状态,没有分配给任何连接。
    for(int i = 0; i < NUM; i++)
    {
    arr_fds[i] = gdefaultfd;
    }
    // 首先把_litenfd 放入数组里面
    // [核心解析]:服务器刚启动时,唯一存在的文件描述符就是监听套接字 (_listenfd)。
    // 必须把它作为第一个“火种”放入辅助数组的 0 号位,让 select 跑起来去感知第一批新连接的到来。
    arr_fds[0] = _listenfd->Socketfd();
    }

    void Start()
    {
    fd_set rfds; // read fd set
    while(true)
    {
    // rfds 参数重置
    // [核心解析]:【高频考点】因为 select 返回时,内核会把“未就绪”的 fd 从 rfds 中清零(输入输出型参数的特性)。
    // 如果不每次清空并重新设置,下一次循环系统就再也不会去监听那些曾经未就绪的 fd 了。
    FD_ZERO(&rfds);

    // [核心解析]:select 的第一个参数要求传入最大 fd 的值。
    // 因为随着客户端的连接和断开,fd 是动态变化的,所以我们必须在每次循环中重新计算出当前托管的 max_fd。
    int max_fd = gdefaultfd;
    for(int i = 0; i < NUM; i++)
    {
    if(arr_fds[i] == gdefaultfd)
    continue;
    // 把新的添加进去
    FD_SET(arr_fds[i], &rfds);
    if(max_fd < arr_fds[i]) max_fd = arr_fds[i]; // 更新最大文件描述符
    }

    // struct timeval timeout = {5,0}; // 我们今天这里先不使用这个
    // int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);

    // [核心解析]:第一个参数必须是 max_fd + 1,这是操作系统底层轮询位图的边界要求。
    // 最后一个参数设为 nullptr,表示采用纯阻塞等待模式,没有 fd 就绪就死等,不消耗 CPU 资源。
    int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
    switch (n)
    {
    case 0:
    LOG(LogLevel::DEBUG) << "time out…";
    break;
    case 1:
    LOG(LogLevel::DEBUG) << "select error…";
    break;
    default:
    LOG(LogLevel::DEBUG) << "事件就绪…: n: " << n;
    // [核心解析]:【重要提示】目前代码的逻辑是只要有事件就绪(n > 0),就统统交由 HandlerEvent 去处理新连接。
    // 这是一个简化版的骨架。实际上在完整的逻辑中,我们还需要在这里遍历 arr_fds,
    // 利用 FD_ISSET() 去精准判断:到底是 _listenfd 就绪了(来新连接了),还是普通的普通通信 fd 就绪了(有数据发来了)。
    HandlerEvent();
    break;
    }
    }
    }

    ~selectServer()
    {}

    private:
    void HandlerEvent()
    {
    InetAddr clientaddr;

    // [核心解析]:这里的 Accept 绝对不会发生阻塞!
    // 因为正是外层的 select 已经帮我们“等”到了底层三次握手完成(读事件就绪),所以我们一调 Accept 就能瞬间拿到新连接。
    int fd = _listenfd->Accepter(&clientaddr);
    LOG(LogLevel::INFO) << "get a new link…";

    // 你得到了一个新的连接,这个连接怎么处理??
    // recv(fd)?? 等 + 拷贝, 不能!!
    // fd -> 托管给select-> 只有select具有"等"的能力!-> 如何托管?? -> 只要把fd添加的辅助数组即可!

    if(fd >= 0)
    {
    // 找一个空闲的位置
    // [核心解析]:由于不能立刻 recv,我们必须把这个刚刚拿到的 fd 存进辅助数组里。
    int pos = 0;
    for(;pos < NUM; pos++)
    {
    if(arr_fds[pos] == gdefaultfd)
    break;
    }

    if(pos == NUM)
    {
    // 遍历完了找不到
    // [核心解析]:数组满了,说明服务器达到了 1024 个并发连接的物理上限。
    // 此时心有余而力不足,只能无情地关闭掉这个新建立的连接,保护服务器不崩溃。
    close(fd);
    }
    else
    {
    // 把这个新获取到的加进辅助数组即可
    // 下一轮循环的时候, 会关心上的
    // [核心解析]:逻辑完美闭环!将新 fd 填入数组的空闲槽位。
    // 当此函数执行完毕,代码流会重返外层 while(true) 的起点。
    // 下一次遍历辅助数组时,这个崭新的 fd 就会自然而然地被 FD_SET 加入位图,正式移交内核的 select 托管!
    arr_fds[pos] = fd;
    }
    }
    }

    private:
    uint16_t _port;
    std::unique_ptr<Socket> _listenfd;
    // 需要一个辅助数组
    int arr_fds[NUM];
    };
    #endif

    5.3 主函数

    主函数非常简单,只需要创建一个selectServer对象并启动即可:

    #include "selectServer.hpp"
    #include <cstdint>
    #include <memory>

    // 定义服务器绑定的默认端口号
    const uint16_t gport = 8080;

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

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

    return 0;
    }

    5.4 代码解读

  • 辅助数组的作用:arr_fds数组是整个select服务器的核心。它保存了所有我们要监听的 fd,包括监听 socket 和所有客户端连接的 socket。每次调用select前,我们都要从这个数组中把所有有效 fd 重新添加到fd_set中。
  • 为什么 accept 不会阻塞:因为select返回时,已经告诉我们监听 socket 上有新连接到来了。此时调用accept,内核中已经有完成三次握手的连接在全连接队列中等待了,所以accept会立即返回,不会阻塞。
  • 新连接的处理:当我们得到一个新的连接 fd 后,只需要把它添加到辅助数组中即可。下一次循环时,这个 fd 就会被自动添加到select的监听集合中,select就会开始监听这个 fd 上的读事件。
  • 5.5 编译运行

    编译运行这个服务器,然后用多个 telnet 客户端连接,你会看到服务器成功接受了所有连接:

    在这里插入图片描述


    结尾:

    🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
    👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
    ❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
    ⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
    💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
    🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
    技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

    结语:本文从多路转接的本质出发,详细讲解了select函数的每一个参数和工作原理,并通过实战代码实现了一个能够接受多个连接的select服务器。目前这个服务器还只是一个骨架,它只能接受新连接,还不能处理客户端发送的数据。在下一篇文章中,我们会继续完善这个服务器,添加客户端数据的读写功能,同时讲解如何处理客户端断开连接的情况,以及如何优化select的事件处理逻辑。虽然select有很多缺点,但它是理解 IO 多路复用的基础。掌握了select,你再去学习poll和epoll就会非常轻松。如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注,我们下期再见!

    ✨把这些内容吃透超牛的!放松下吧✨

    ʕ˘ᴥ˘ʔ

    づきらど

    在这里插入图片描述

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux网络】深入理解Linux IO多路复用:从本质到select服务器实战
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!