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


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

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

前言

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


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

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

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

  • :由selectpollepoll这类专门的函数来做,一次可以同时等多个文件描述符(fd)
  • 拷贝 :还是由原来的readrecvwritesend来做,只有当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函数的原型如下:

c 复制代码
#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结构体的定义大致如下:

c 复制代码
typedef struct {
    long int fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;

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

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

c 复制代码
// 清空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

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

timeout有三种取值:

  • NULLselect会永久阻塞,直到有 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 就处于读就绪状态:

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

3.2 写就绪

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

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

3.3 异常就绪(选学)

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


四. select 的编程特点与缺点

4.1 select 的编程特点

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

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

4.2 select 的缺点

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

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

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

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

5.1 基础组件回顾

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

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

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

c 复制代码
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类的核心代码如下:

cpp 复制代码
#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对象并启动即可:

cpp 复制代码
#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 代码解读

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

5.5 编译运行

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


结尾:

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

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

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
fen_fen9 小时前
麒麟 Linux aarch64安装.NET 6,并适配.net应用(达梦数据库)
linux·服务器·.net
秋风渡.9 小时前
MySQL数据库操作
数据库·mysql
fen_fen9 小时前
Filebeat 采集 应用日志部署 & Kibana 查询操作文档
linux·运维·服务器
李燚9 小时前
LLM 可靠性设计:Retry + Failover 源码拆解(第58篇-E44)
数据库
乐橙开放平台9 小时前
养殖 SaaS 笔记:乐橙 IoT 物模型管环境,视频 OpenAPI 管回看
数据库·笔记·物联网·mysql·音视频
苏宸啊9 小时前
linux线程池
linux
草莓熊Lotso9 小时前
【LangChain】输出解析器全解:让大模型输出从 “聊天” 变 “机器可读”
服务器·数据库·python·langchain·pip
huohuopro9 小时前
手写 Tomcat 步骤教程
java·网络