【Linux网络#18】:深入理解select多路转接:传统I/O复用的基石

📃个人主页:++island1314++

🔥个人专栏:++Linux---登神长阶++


目录

    • [一、前言:🔥 I/O 多路转接](#一、前言:🔥 I/O 多路转接)
    • [二、I/O 多路转接之 select](#二、I/O 多路转接之 select)
      • [1. 初识 select](#1. 初识 select)
      • [2. select 函数原型](#2. select 函数原型)
        • [2.1 关于 fd_set 结构](#2.1 关于 fd_set 结构)
        • [2.2 函数返回值](#2.2 函数返回值)
      • [3. 理解 select 执行过程](#3. 理解 select 执行过程)
        • [3.1 socket 就绪条件](#3.1 socket 就绪条件)
        • [3.2 select 的特点](#3.2 select 的特点)
        • [3.3 select 优缺点](#3.3 select 优缺点)
        • [3.4 注意事项](#3.4 注意事项)
      • [4. 代码示例](#4. 代码示例)
      • [5. 使用场景](#5. 使用场景)
    • 三、后言

一、前言:🔥 I/O 多路转接

💻 多路I/O转接服务器 \colorbox{cyan}{ 多路I/O转接服务器 } 多路I/O转接服务器 (或称为多任务I/O服务器)是一种高效管理多个I/O操作的技术,允许单线程或单进程同时监控和处理多个I/O事件(如网络套接字、文件描述符等)

  • 核心思想:利用操作系统提供的多路I/O转接机制(如 selectpollepoll 等),由内核帮助应用程序高效地监视多个文件描述符(包括网络连接、管道、文件等)的状态变化,而不是让应用程序自己轮询每个连接的状态
  • 核心目标:用最小资源开销实现高并发I/O处理,尤其适用于需要同时处理大量连接的场景(如Web服务器、实时通信系统等)
  • 这种方式能够显著提高服务器的性能和可扩展性,尤其是在处理大量并发连接时

为什么需要I/O多路转接?

传统阻塞I/O模型中,每个I/O操作会阻塞线程直至完成。若需处理多个连接,通常需为每个连接分配独立线程/进程,导致资源消耗大、上下文切换频繁。

而I/O多路转接通过单线程监控多个I/O流,仅在I/O就绪时触发操作,避免了阻塞和资源浪费。


二、I/O 多路转接之 select

1. 初识 select

💻 系统提供 select \colorbox{pink}{ select } select 函数来实现多路复用 输入 / 输出 模型.

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

核心原理

  • select 是一种 同步I/O多路复用 机制,允许程序在一个线程中监听多个文件描述符(如套接字、文件等)的可读、可写或异常事件
  • 其核心是通过 **轮询(polling)**检查文件描述符状态,并阻塞等待直到至少一个描述符就绪或超时。

2. select 函数原型

💤 select 的函数原型如下:

cpp 复制代码
#include <sys/select.h>

int select(
    int nfds,               // 监控的最大文件描述符值 +1
    fd_set *readfds,         // 监听可读事件的描述符集合
    fd_set *writefds,        // 监听可写事件的描述符集合
    fd_set *exceptfds,       // 监听异常事件的描述符集合
    struct timeval *timeout  // 超时时间(NULL为无限等待)
);

// 操作fd_set的宏:
FD_ZERO(fd_set *set);        // 清空集合
FD_SET(int fd, fd_set *set); // 添加描述符到集合
FD_ISSET(int fd, fd_set *set); // 检查描述符是否在集合中
FD_CLR(int fd, fd_set *set); // 从集合移除描述符

📚 参数解释:

  • nfds 是需要监视的最大的文件描述符值 +1
  • rdset , wrset , exset 分别对应于需要检测的可读文件描述符的集合 , 可写文件描述符的集合 及 异常文件描述符的集合
  • timeout 为 结构体 timeval, 用来设置 select() 的等待时间
cpp 复制代码
/* A time value that is accurate to the nearest
   microsecond but also has a range of years.  */
struct timeval
{
  __time_t tv_sec;        /* Seconds.  */
  __suseconds_t tv_usec;    /* Microseconds.  */
};

📚 参数 timeout 取值:

  • NULL : 则表示 select() 没有 timeoutselect 将一直被阻塞, 直到某个文件描述符上发生了事件

  • 0 : 仅检测描述符集合的状态, 然后立即返回, 并不等待外部事件的发生(非阻塞

  • 特定的时间值struct timeval timeout = {10, 0} : 如果在指定的时间段里没有事件发生,select 将超时返回

2.1 关于 fd_set 结构
c++ 复制代码
typedef long int __fd_mask;


/* It's easier to assume 8-bit bytes than to get CHAR_BIT. */
#define __NFDBITS (8 * (int) sizeof (__fd_mask))
#define __FDELT(d) ((d) / __NFDBITS)
#define __FDMASK(d) ((__fd_mask) 1 << ((d) % __NFDBITS))

/* fd_set for select and pselect. */
typedef struct
  {
    /* XPG4.2 requires this member name. Otherwise avoid the name
       from the global namespace. */
#ifdef __USE_XOPEN
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
  } fd_set;

/* Maximum number of file descriptors in `fd_set'. */
#define FD_SETSIZE __FD_SETSIZE   //__FD_SETSIZE等于1024

/* Access macros for `fd_set'.  */
#define FD_SET(fd, fdsetp)      __FD_SET (fd, fdsetp)
#define FD_CLR(fd, fdsetp)      __FD_CLR (fd, fdsetp)
#define FD_ISSET(fd, fdsetp)    __FD_ISSET (fd, fdsetp)
#define FD_ZERO(fdsetp)         __FD_ZERO (fdsetp)
  • 其实这个结构就是一个 整数数组 ,更严格的说, 是一个 "位图" . 使用位图中对应的位来表示要监视的文件描述符.
    • 一个long int类型的数组。因为每一位可以代表一个文件描述符。所以fd_set最多表示1024个文件描述符!
  • 提供了一组操作 fd_set 的接口, 来比较方便的操作位图
cpp 复制代码
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 的全部位
2.2 函数返回值
  • 执行成功则返回 文件描述符状态已改变的个数
  • 如果返回 0 代表在描述符状态改变前已超过 timeout 时间
  • 当有错误发生时则返回-1 , 错误原因存于 errno, 此时参数 readfds, writefds, exceptfds 和 timeout 的值变成不可预测

🙅 错误值可能为:

  • EBADF文件描述词为无效的或该文件已关闭
  • EINTR此调用被信号所中断
  • EINVAL参数 n 为负值
  • ENOMEM核心内存不足

3. 理解 select 执行过程

🦈 理解 select 模型的关键在于理解 fd_set , 为说明方便, 取 fd_set 长度为 1 字节, fd_set 中的每一 bit 可以对应一个文件描述符 fd_set 。 则 1 字节长的 fd_set 最大可以对应 8 个 fd.

  • 执行 fd_set ; FD_ZERO(&set);set 用位表示是 0000,0000
  • 若 fd= 5,执行 FD_SET(fd,&set); 后 set 变为 0001,0000(第 5 位置为 1)
  • 若再加入 fd= 2, fd=1,则 set 变为 0001,0011
  • 执行 select(6,&set,0,0,0) 阻塞等待
  • select 返回, 此时 set 变为 0000,0011。 注意: 没有事件发生的 fd=5 被清空
3.1 socket 就绪条件
读就绪
  • socket 内核中, 接收缓冲区中的字节数, 大于等于低水位标记 SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于 0;
  • socket TCP 通信中, 对端关闭连接, 此时对该 socket 读, 则返回 0;
  • 监听的 socket 上有新的连接请求;
  • socket 上有未处理的错误;
写就绪
  • socket 内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记 SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于 0;
  • socket 的写操作被关闭(close 或者 shutdown ). 对一个写操作被关闭的 socket 进行写操作, 会触发 SIGPIPE 信号;
  • socket 使用非阻塞 connect 连接成功或失败之后;
  • socket 上有未读取的错误;
异常就绪(选学)
  • socket 上收到带外数据. 关于带外数据, 和 TCP 紧急模式相关(回忆 TCP 协议头中, 有一个紧急指针的字段), 自己收集相关资料
3.2 select 的特点
  • 可监控的文件描述符个数取决于 sizeof(fd_set) 的值. 我这边服务器上 sizeof(fd_set)= 512, 每 bit 表示一个文件描述符, 则我服务器上支持的最大文件描述符是 512*8=4096.
  • I将 fd 加入 select 监控集的同时, 还要再使用一个数据结构 array 保存放到 select 监控集中的 fd
    1. 用于再 select 返回后, array 作为源数据和 fd_set 进行 FD_ISSET 判断**
    2. select 返回后会把以前加入的但并无事件发生的 fd 清空, 则每次开始 select 前都要重新从 array 取得 fd 逐一加入(FD_ZERO 最先), 扫描 array 的同时 取得 fd 最大值 maxfd, 用于 select 的第一个参数

备注: fd_set 的大小可以调整, 可能涉及到重新编译内核.

3.3 select 优缺点
优点 缺点
跨平台支持(所有UNIX/Linux系统) 文件描述符数量受限 (默认1024,由FD_SETSIZE定义)
简单易用,适合少量并发场景 线性扫描,时间复杂度O(n)(效率随描述符数量下降)
超时机制灵活 每次调用需重置fd_set(额外内存拷贝开销)
  • 每次调用 select:都需要手动设置 fd 集合(从用户态拷贝到内核态), 从接口使用角度来说也非常不便,而且 这个开销在 fd 很多时会很大
  • 同时每次调用 select 都需要在内核遍历传递进来的所有 fd, 这个开销在 fd 很多时也很大
3.4 注意事项
  1. 描述符上限 :通过 FD_SETSIZE 宏定义(通常1024),需重新编译内核修改。
  2. 性能问题 :当监控数千描述符时,select 的轮询效率远低于 epollkqueue
  3. 水平触发select 是水平触发模式,若未处理就绪事件,会持续通知。
  4. 非阻塞I/O :结合非阻塞socket可避免单次read/write阻塞整个程序。

4. 代码示例

示例一:检测标准输入输出

c++ 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>

int main()
{
    fd_set read_fds;
    FD_ZERO(&read_fds); // 清空
    FD_SET(0, &read_fds);

    while(true)
    {
        printf("> ");
        fflush(stdout);
        int ret = select(1, &read_fds, NULL, NULL, NULL);
        if(ret < 0){
            perror("Select");
            continue;
        }
        if(FD_ISSET(0, &read_fds)){
            char buf[1024] = {0};
            read(0, buf, sizeof(buf) - 1);
            printf("Input: %s", buf);
        }
        else{
            printf("Error! Invalid fd\n");
            continue;
        }
        FD_ZERO(&read_fds);
        FD_SET(0, &read_fds);
    }

    return 0;
}
  • 当只检测文件描述符 0(标准输入)时,因为输入条件只有在你有输入信息的时候才成立,所以如果一直不输入,就会产生超时信息

示例二:TCP 服务器使用 select 处理多客户端

cpp 复制代码
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <sys/select.h>
#include <cstring>

#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};

    // 创建TCP socket
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置socket选项(允许地址重用)
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // 绑定socket到端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 开始监听
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    fd_set readfds;  // 描述符集合
    int client_sockets[MAX_CLIENTS] = {0}; // 客户端socket数组
    int max_sd;

    while (true) {
        FD_ZERO(&readfds);           // 清空集合
        FD_SET(server_fd, &readfds); // 添加服务器socket到监听集合
        max_sd = server_fd;

        // 添加所有客户端socket到集合
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (sd > 0) {
                FD_SET(sd, &readfds);
                if (sd > max_sd) max_sd = sd;
            }
        }

        // 调用select,阻塞等待事件
        int activity = select(max_sd + 1, &readfds, NULL, NULL, NULL);
        if ((activity < 0) && (errno != EINTR)) {
            perror("select error");
        }

        // 检查服务器socket是否有新连接
        if (FD_ISSET(server_fd, &readfds)) {
            if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
                perror("accept");
                exit(EXIT_FAILURE);
            }

            // 将新客户端socket加入数组
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_sockets[i] == 0) {
                    client_sockets[i] = new_socket;
                    std::cout << "New client connected, socket fd: " << new_socket << std::endl;
                    break;
                }
            }
        }

        // 处理客户端数据
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (FD_ISSET(sd, &readfds)) {
                int valread = read(sd, buffer, BUFFER_SIZE);
                if (valread == 0) {  // 客户端断开连接
                    getpeername(sd, (struct sockaddr*)&address, (socklen_t*)&addrlen);
                    std::cout << "Client disconnected" << std::endl;
                    close(sd);
                    client_sockets[i] = 0;  // 清除socket
                } else {  // 处理数据
                    buffer[valread] = '\0';
                    std::cout << "Received: " << buffer << std::endl;
                    send(sd, buffer, strlen(buffer), 0); // 回显数据
                }
            }
        }
    }

    return 0;
}
  1. 初始化服务器
    • 创建TCP socket,绑定端口并开始监听。
    • 设置 SO_REUSEADDR 允许地址重用(避免端口占用)。
  2. select 监听流程
    • 使用 fd_set 管理需要监听的描述符集合。
    • 每次循环重新初始化集合,添加服务器socket和所有客户端socket。
    • 调用 select 阻塞等待事件,返回就绪的描述符数量。
  3. 处理新连接
    • 当服务器socket就绪(FD_ISSET),调用 accept 接受新连接。
    • 将新客户端socket存入数组。
  4. 处理客户端数据
    • 遍历所有客户端socket,检查是否有数据可读。
    • read 返回0,表示客户端断开连接,关闭socket并清理数组。
    • 否则回显接收到的数据。

5. 使用场景

  • 需要兼容多平台的轻量级应用。
  • 并发连接数较少(如<1000)。
  • 超时机制需要精细控制的场景(如同时等待I/O和定时任务)

三、后言

★,° :.☆( ̄▽ ̄)/$:.°★ 】那么本篇到此就结束啦,如果有不懂 和 发现问题的小伙伴可以在评论区说出来哦,同时我还会继续更新关于【Linux】的内容,比如:多路转接之 epollpoll 模型,请持续关注我 !!

相关推荐
专注API从业者3 分钟前
基于 Flink 的淘宝实时数据管道设计:商品详情流式处理与异构存储
大数据·前端·数据库·数据挖掘·flink
花小璇学linux19 分钟前
imx6ull-驱动开发篇24——Linux 中断API函数
linux·驱动开发·嵌入式软件
林开落L1 小时前
库制作与原理(下)
linux·开发语言·centos·库制作与原理
小猿姐1 小时前
KubeBlocks for Milvus 揭秘
数据库·云原生
AI 嗯啦1 小时前
SQL详细语法教程(四)约束和多表查询
数据库·人工智能·sql
wxy3191 小时前
嵌入式LINUX——————TCP并发服务器
java·linux·网络
Castamere1 小时前
配置 Linux 终端 (zsh)
linux
杜子不疼.1 小时前
《Python学习之文件操作:从入门到精通》
数据库·python·学习
杭州泽沃电子科技有限公司2 小时前
告别翻山越岭!智能监拍远程守护输电线路安全
运维·人工智能·科技·安全
TDengine (老段)2 小时前
TDengine IDMP 高级功能(4. 元素引用)
大数据·数据库·人工智能·物联网·数据分析·时序数据库·tdengine