多路转接select系统调用详解

此文章对应五种IO模型的1.3:I/O多路复用select系统调用的讲解

目录

  • [1. 初识select](#1. 初识select)
  • [2. select函数原型](#2. select函数原型)
    • [2.1 参数解释](#2.1 参数解释)
    • [2.2 函数返回值](#2.2 函数返回值)
    • [2.3 关于fd_set结构](#2.3 关于fd_set结构)
    • [2.4 理解readfds、writefds、exceptfds](#2.4 理解readfds、writefds、exceptfds)
  • [3. 理解select执行过程](#3. 理解select执行过程)
  • [4. socket就绪条件](#4. socket就绪条件)
  • [5. select的特点](#5. select的特点)
  • [6. select缺点](#6. select缺点)
  • [7. select使用示例](#7. select使用示例)
    • [7.1 检测标准输入输出](#7.1 检测标准输入输出)
    • [7.2 使用select实现网络消息传送](#7.2 使用select实现网络消息传送)

1. 初识select

select 是系统提供的经典 IO 多路复用实现,通过 select 系统调用可搭建多路复用输入 / 输出模型,是实现单进程 / 线程管理多个 IO 操作的核心接口之一,核心特性如下:

  • select 系统调用的核心作用,是让程序统一监视多个文件描述符的可读、可写、异常三类核心状态变化
    • 什么叫可读?底层有数据,读事件就绪
    • 什么叫可写?底层有空间,写事件就绪
  • 调用 select 后,进程 / 程序会有阻塞和非阻塞状态,非阻塞状态时的返回值有所不同;阻塞状态,直到被监视的文件描述符中有一个或多个发生了指定的状态变化,或达到预设的等待超时时间,阻塞才会解除。

总结:select就是:通过等待多个fd状态变化的一种就绪事件通知机制!

2. select函数原型

select 的函数原型如下(Linux 系统编程标准接口):

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

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

2.1 参数解释

  • nfds :需要监视的最大文件描述符值 + 1(文件描述符从 0 开始遍历检测,此参数用于限定内核检测的范围,提升效率);

  • readfds :可读文件描述符集合的指针,传入需监视的可读 fd,返回后仅保留发生可读状态变化的 fd;

  • writefds :可写文件描述符集合的指针,传入需监视的可写 fd,返回后仅保留发生可写状态变化的 fd;

  • exceptfds :异常文件描述符集合的指针,传入需监视的异常 fd,返回后仅保留发生异常状态变化的 fd;

    注:以上三个集合参数,若无需监视对应状态,可传入NULL

  • timeouttimeval结构体指针,用于设置 select 的阻塞等待超时时间,内核会在该时间内等待 fd 状态变化,超出时间则直接返回,有三种核心取值:

    1. NULL:永久阻塞,直到被监视的 fd 中有状态变化才解除阻塞;
    2. 时间值全为 0:非阻塞模式,立即检测所有 fd 状态并返回,不做任何等待;
    3. 设定具体时间值:超时阻塞,若超时前无 fd 状态变化,到点后自动返回。

补充:timeval 结构体定义

timeout 依赖的timeval结构体用于精确设置秒和微秒级超时时间,定义如下:

cpp 复制代码
struct timeval {
    long tv_sec;  // 秒
    long tv_usec; // 微秒(1秒=10^6微秒)
};

2.2 函数返回值

select 调用的返回值为 int 类型,不同返回值对应不同执行结果,仅返回值大于 0 时表示有文件描述符状态发生变化,具体说明如下:

  • 返回值 > 0 :执行成功,返回状态发生变化的文件描述符总数(可读、可写、异常状态的 fd 数量累加);
  • 返回值 = 0 :超时返回,在timeout设定的时间内,无任何被监视的文件描述符状态发生变化,程序解除阻塞继续执行;
  • 返回值 = -1 :调用失败,此时错误原因会存入系统全局变量errno传入的 readfds、writefds、exceptfds 和 timeout 参数值会变得不可预测,不可继续使用

常见错误值(errno)及含义

select 调用失败时,errno常见的取值及对应错误原因如下:

  • EBADF:传入的文件描述符为无效值,或对应的文件 / 套接字已被关闭;
  • EINTR :select 调用过程中被系统信号中断(如收到 SIGINT、SIGCHLD 等信号);
  • EINVAL :参数非法,常见原因是nfds为负值,或timeout结构体中的时间值设置无效(如微秒数 tv_usec≥10^6);
  • ENOMEM:内核内存资源不足,无法完成 select 的相关初始化操作。

补充使用要点

若因EINTR导致 select 调用失败,通常可在代码中做重入处理(重新调用 select),这类情况属于可恢复的错误,并非真正的程序异常。

常见的程序片段如下:

cpp 复制代码
fs_set readset;
FD_SET(fd,&readset);
select(fd+1,&readset,NULL,NULL,NULL);
if(FD_ISSET(fd,readset)){......}

2.3 关于fd_set结构

fd_set是系统为 select 专门定义的文件描述符集合类型 ,其底层实现为整数数组,本质是一块位图 ,通过位图中每一位的置 1 / 置 0 来标识对应的文件描述符是否被纳入监视范围 ------位图的第 n 位,对应文件描述符 fd=n,位为 1 表示监视该 fd,位为 0 表示不监视。

为避免直接操作位图带来的位运算错误、提升代码可读性,系统提供了一组专用的接口函数来操作fd_set,无需开发者手动处理位运算,接口如下:

cpp 复制代码
#include <sys/select.h>
// 清除描述符集合set中,fd对应的位(置0,取消对该fd的监视)
void FD_CLR(int fd, fd_set *set);
// 检测描述符集合set中,fd对应的位是否为1(非0表示已置1,0表示未置1)
int FD_ISSET(int fd, fd_set *set);
// 设置描述符集合set中,fd对应的位(置1,加入对该fd的监视)
void FD_SET(int fd, fd_set *set);
// 将描述符集合set中所有位清0,初始化空集合
void FD_ZERO(fd_set *set);

2.4 理解readfds、writefds、exceptfds

这三个参数是输入输出型参数

以readfds为例,其余同理:

输入时:

用户告诉内核,你要帮我关心哪些fd上的读事件

比特位的位置,表示fd编号,比特位的内容,表示是否关心。

例如:0000 1111,表示只关心0~3这四个fd

输出时:

内核告诉用户,你让我关心的哪些fd上面的读事件已经就绪

比特位的位置,表示fd的编号,比特位的内容,表示是否就绪

例如:0000 0010,表示只有fd=1的文件描述符的内容是就绪的。

fd_set是位图结构,一次性可以向里面添加多个fd

细节:

  1. 位图是输入输出的,所以,将来这个位图一定会被频繁变更
  2. 位图有多少个比特位,就决定了select最多能关心多少个fd。(fd_set是一个系统提供的数据类型(struct):fd_set大小固定!)
  3. readfds:如果把fd添加到readfds集合中,表示改fd,只关心读事件。告诉内核,你只需要帮我关心fd的读事件。

3. 理解select执行过程

理解 select 模型的核心,是掌握内核对 fd_set 的修改逻辑 。为简化理解,我们做一个经典的简化假设:取fd_set的长度为 1 字节(实际系统中fd_set长度由宏FD_SETSIZE定义,Linux 默认对应 1024 个 fd),fd_set的每 1 个二进制位对应 1 个文件描述符 fd,位图的第 n 位对应文件描述符 fd=n ,1 字节的fd_set最多可监视 fd=0~7 共 8 个文件描述符。

以下通过可读事件监视的完整步骤,直观演示 select 的执行过程:

  1. 定义并初始化 fd_set 集合:执行fd_set set; FD_ZERO(&set);,将集合所有位清 0,此时 set 的二进制表示为 0000 0000
  2. 添加需监视的 fd:若要监视 fd=5 的可读事件,执行FD_SET(5, &set);,将第 5 位置 1,此时 set 变为 0001 0000
  3. 继续添加监视 fd:再依次执行FD_SET(2, &set);FD_SET(1, &set);,将第 2 位、第 1 位置 1,此时 set 变为 0001 0011
  4. 调用 select 阻塞等待:执行select(6, &set, NULL, NULL, NULL);,进入阻塞状态。👉 关键参数呼应:nfds=6是监视的最大 fd(5)+1;后三个参数为 NULL,表示仅监视可读事件、不监视可写 / 异常事件、永久阻塞(直到有 fd 触发事件);
  5. 内核检测到事件,select 解除阻塞并返回:若此时 fd=1、fd=2 上触发了可读事件 ,fd=5 无任何事件,select 会立即返回(返回值为 2,对应 2 个状态变化的 fd),内核会重写传入的 fd_set 集合 ,仅保留触发事件的 fd 对应位为 1,未触发的位全部清 0,此时 set 变为 0000 0011(fd=5 对应的位被清空)。

核心执行要点

select 的执行过程,本质是用户层设置监视集合 → 内核层检测 fd 状态 → 内核重写集合并返回的过程,其中最关键的是:

  • select 调用返回后,原传入的 fd_set 会被内核覆盖重写,仅保留触发对应事件的 fd 位为 1,未触发事件的 fd 位会被强制清 0;
  • 正因内核会重写 fd_set,每次重新调用 select 前,都需要重新执行 FD_ZERO 初始化 + FD_SET 设置监视 fd,否则会因脏数据导致监视异常。

4. socket就绪条件

select 监视 socket 触发的可读、可写、异常事件,本质是检测 socket 满足就绪条件,只有当 socket 符合对应就绪规则时,内核才会将其标记为状态变化,触发 select 返回。以下是 Linux 下 socket 触发可读、可写、异常事件的核心就绪条件,其中异常就绪为选学内容。

读就绪(socket 触发可读事件的条件)

满足以下任一条件,socket 即处于读就绪状态,对其执行无阻塞读操作可直接获取结果:

  1. socket 内核接收缓冲区中的数据字节数,大于等于低水位标记 SO_RCVLOWAT(Linux 系统默认值为 1 字节),此时无阻塞读会成功,且返回值大于 0;
  2. TCP 通信中,对端主动关闭连接(发送 FIN 包),此时对该 socket 执行读操作,会直接返回 0(表示读到流结束);
  3. 处于 listen 状态的监听 socket上有新的客户端连接请求,此时调用 accept () 可无阻塞获取新的连接描述符;
  4. socket 上存在未处理的错误,此时对其执行无阻塞读操作会返回 - 1,且系统全局变量 errno 会置为对应的错误码。

写就绪(socket 触发可写事件的条件)

满足以下任一条件,socket 即处于写就绪状态,对其执行无阻塞写操作可直接获取结果:

  1. socket 内核发送缓冲区的可用空闲字节数 (剩余可写入空间),大于等于低水位标记 SO_SNDLOWAT(Linux 系统默认值为 1024 字节),此时无阻塞写会成功,且返回值大于 0;
  2. socket 的写端被关闭 (如调用 shutdown (fd, SHUT_WR) 关闭写端,或 TCP 对端关闭连接),此时对该 socket 执行写操作会触发SIGPIPE 信号,若忽略该信号,写操作会返回 - 1;
  3. 对 socket 执行非阻塞 connect () 后,连接成功建立或连接失败,此时 socket 会触发写就绪,可通过 getsockopt () 获取连接结果;
  4. socket 上存在未处理的错误,此时对其执行无阻塞写操作会返回 - 1,且系统全局变量 errno 会置为对应的错误码。

异常就绪(选学)

socket 触发异常就绪的核心条件为:socket 接收到 TCP 带外数据(紧急数据)

  • 带外数据与 TCP 协议的紧急模式 相关,TCP 协议头中的紧急指针字段用于标识带外数据的位置,内核会为带外数据开辟独立的缓冲区,不会与普通数据混存;
  • 带外数据的传输优先级高于普通数据,触发异常就绪后,可通过 recv ()/recvmsg () 的 MSG_OOB 标志读取带外数据,同学们可课后自行收集相关资料深入学习。

5. select的特点

select 作为经典的 IO 多路复用实现,其设计特性决定了使用上的核心特点,同时也存在明显的固有局限性,核心特点如下:

  1. 可监视的文件描述符数量存在硬限制

    select 能监视的最大 fd 数量,由fd_set的底层大小决定,而fd_set的容量由系统宏FD_SETSIZE固定定义(与sizeof(fd_set)一一对应)。例如服务器上sizeof(fd_set)=512字节,每 1 位对应 1 个 fd,则最大可监视512*8=4096个文件描述符;常见 Linux 系统默认FD_SETSIZE=1024(即sizeof(fd_set)=128字节),默认最大监视 1024 个 fd。

    该限制为内核级硬限制,若需调整需重新编译内核,开发中一般不建议修改,这也是 select 在高并发场景下的主要缺陷。

  2. 必须额外维护独立数据结构保存待监视的 fd

    由于 select 返回后,内核会清空 fd_set 中无事件发生的 fd 对应位 ,仅保留触发事件的 fd 位,导致原监视集合被覆盖无法复用。因此实际开发中,需在将 fd 加入 select 监视集合的同时,额外使用一个独立的数组(如 int 数组) 保存所有待监视的 fd,该数组的核心作用有两点:

    • 作为 select 返回后的检测源:select 返回后,遍历该数组,结合FD_ISSET逐个判断数组中的 fd 是否在改写后的 fd_set 中,以此确定具体哪个 fd 触发了事件,避免盲目遍历所有可能的 fd,提升检测效率;
    • 作为重新初始化监视集合的数据源:每次调用 select 前,需先执行FD_ZERO清空 fd_set,再遍历该数组,通过FD_SET将所有待监视 fd 重新加入集合;同时遍历数组的过程中,可同步获取待监视 fd 的最大值maxfd,为 select 的第一个参数nfdsmaxfd+1)提供准确值。

延伸使用要点

正因为 select 存在 "需重新初始化监视集合" 的特点,每次调用 select 都需要执行 FD_ZERO、FD_SET 的初始化操作,且需遍历数组获取 maxfd,这些操作会随着待监视 fd 数量的增加带来额外的性能开销,这也是 select 在高并发场景下性能下降的原因之一。

6. select缺点

select 虽实现了 IO 多路复用,但受限于设计年代的技术背景,存在多处固有缺陷,使其在高并发网络场景下性能表现不佳,核心缺点如下:

  • 接口使用繁琐,需手动重复初始化 fd 集合:因 select 返回后内核会覆盖重写 fd_set,每次调用 select 前都必须手动执行 FD_ZERO 清空集合 + FD_SET 重新添加待监视 fd,且需额外维护独立数组保存 fd,从开发使用角度来说操作繁琐、代码冗余。
  • fd 集合存在用户态到内核态的频繁拷贝开销:每次调用 select,都需要将用户态的 fd_set 完整拷贝到内核态,待监视的 fd 数量越多,拷贝的数据量越大,系统内存和 CPU 的开销会显著增加。
  • 内核层存在全量 fd 遍历检测开销 :内核接收到拷贝后的 fd_set 后,会逐个遍历所有待监视的 fd,检测其是否处于就绪状态,fd 数量越多,遍历的耗时越长,高并发场景下该操作会成为明显的性能瓶颈。
  • 可监视的 fd 数量存在内核级硬限制 :能监视的最大 fd 数由系统宏FD_SETSIZE固定定义(默认多为 1024),该限制无法通过简单的代码修改突破,需重新编译内核,完全无法适配高并发的网络服务场景。

7. select使用示例

7.1 检测标准输入输出

只检测标准输入:

cpp 复制代码
#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);
    
    for (;;) {
        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! invaild fd\n");
            continue;
        }
        FD_ZERO(&read_fds);
        FD_SET(0, &read_fds);
    }
    return 0;
}

说明:

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

7.2 使用select实现网络消息传送

  1. main函数创建SelectServer类型的智能指针svr

构造函数

  1. 创建TcpSocket套接字
  2. 将存储文件描述符的数组清空
  1. svr调用SelectServer类中的Start函数
  1. 创建select函数需要的变量,fd_set rfds
  2. 使用FD_ZERO函数将rfds清空
  3. select函数的第一个参数需要放置最大文件描述符+1,所以要判断并存放最大文件描述符
  4. 调用select函数,timeout使用nullptr(阻塞模式)
  5. 跟据select函数的返回值n来判断是否可以调用后续处理函数
  1. 当n>0时表示有事件就绪,调用Dispatcher事件派发器函数
  1. 找到已经就绪的文件描述符判断是连接就绪还是普通读事件就绪,并执行不同的后续函数
  1. 如果是连接就绪,调用Accepter连接管理器函数,添加连接;如果连接超过限制,则关闭连接请求
  2. 如果是普通事件就绪,调用IO处理器Recver函数。(此处只使用了读事件,功能是将收到的数据进行打印)

代码测试指令

make # 编译

./selectserver 8080 # 开启服务端

telnet 127.0.0.1 8080 # 本机客户端测试,新开终端;连接成功后输入任意数据,即可在服务端回显
【免费】linux网络-多路转接select的使用资源-CSDN下载

SelectServer.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <memory>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"

using namespace SocketModule;
using namespace LogModule;

class SelectServer
{
    const static int size = sizeof(fd_set) * 8;
    const static int defaultfd = -1;

public:
    SelectServer(int port)
        : _listensock(std::make_unique<TcpSocket>()),
          _isrunning(false)
    {
        _listensock->BuildTcpSocketMethod(port);
        for (int i = 0; i < size; i++)
        {
            _fd_array[i] = defaultfd;
        }
        _fd_array[0] = _listensock->Fd();
    }
    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            // 因为:listensockfd 也是一个fd, 进程怎么知道listenfd上面有新连接到来呢?
            // auto res = _listensock->Accept(); // 我们在select 这里,可以进行accpet马?
            // 将listencoskfd添加到select内部, 让OS帮我关心listensockfd上面的读事件
            fd_set rfds;    // 定义 读  fds集合
            FD_ZERO(&rfds); // 清空fds
            int maxfd = defaultfd;
            for (int i = 0; i < size; i++)
            {
                if (_fd_array[i] == defaultfd)
                    continue;
                // 1.每次select之前,都要对rfds进行重置
                FD_SET(_fd_array[i], &rfds); // 有没有设置到内核中 ? 1 or 0
                // 2.最大fd,一定是变化的
                if (maxfd < _fd_array[i])
                {
                    maxfd = _fd_array[i]; // 更新出最大fd
                }
            }
            PrintFd();

            // struct timeval timeout = {2, 0};
            // select 返回之后,你怎么还知道哪些fd需要被添加到rfds,让select关心呢?
            // 所以:select要进行完整的设计,需要借助一个辅助数组!保存服务器历史获取过的所有的fd

            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            switch (n)
            {
            case -1:
                LOG(LogLevel::ERROR) << "select error";
                break;
            case 0:
                LOG(LogLevel::INFO) << "time out ...";
                break;
            default:
                LOG(LogLevel::DEBUG) << "有事件就绪了..., n:" << n;
                Dispatcher(rfds);
                break;
            }
        }
        _isrunning = false;
    }
    // 事件派发器
    void Dispatcher(fd_set &rfds)
    {
        // 不仅仅是新连接到来,也包括读事件就绪 // 指定的文件描述符,在rfds里面,就证明该fd就绪了
        for(int i = 0; i < size; i++)
        {
            if(_fd_array[i] == defaultfd)
                continue;
            // fd合法,不一定就绪
            if(FD_ISSET(_fd_array[i], &rfds))
            {
                // fd_array[i] 上面一定是读就绪了
                // listensockfd 新连接到来,也是读事件就绪
                // sockfd 数据到来,读事件就绪
                if(_fd_array[i] == _listensock->Fd())
                {
                    // listensockfd 新连接到来
                    Accepter();
                }
                else
                {
                    // 普通的事件就绪
                    Recver(_fd_array[i],i);
                }
            }
        }
    }
    // 链接管理器:将新连接的文件描述符,添加到_fd_array数组中
    void Accepter()
    {
        InetAddr client;
        int sockfd = _listensock->Accept(&client);  // accept会不会阻塞
        if(sockfd >= 0)
        {
            //  获取新连接到来成功,然后呢??能不能直接read/recv?,coskfd是否读就绪,我们不清楚
            // 只有谁最清楚?未来sockfd上是否有事件就绪?答:select!
            // 将新的sockfd,托管给select!
            // 如何托管?将新的fd放入辅助数组!!
            LOG(LogLevel::INFO) << "get a new link, sockfd: " << sockfd << ", client is: " << client.StringAddr();

            int pos = 0;
            for(; pos < size; pos++)
            {
                if(_fd_array[pos] == defaultfd)
                    break;
            }
            if(pos == size)
            {
                LOG(LogLevel::WARNING) << "select server full";
                close(sockfd);
            }
            else
            {
                _fd_array[pos] = sockfd;
            }
        }
    }

    // IO处理器
    void Recver(int fd, int pos)
    {
        char buffer[1024];
        // 我在这里读取的时候,会不会阻塞??
        ssize_t n = recv(fd, buffer, sizeof(buffer)-1,0);   // recv写的时候有bug吗?
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say@ " << buffer << std::endl;
        }
        else if(n == 0)
        {
            LOG(LogLevel::INFO) << "client quit...";
            // 1.不要让select在关心这个fd了
            _fd_array[pos] = defaultfd;
            // 2.关闭fd
            close(fd);
        }
        else
        {
            LOG(LogLevel::ERROR) << "recv error";
            // 1.不要让select在关心这个fd了
            _fd_array[pos] = defaultfd;
            // 2.关闭fd
            close(fd);
        }
    }
    void PrintFd()
    {
        std::cout << "_fd_array[]: ";
        for(int i = 0; i < size; i++)
        {
            if(_fd_array[i] == defaultfd)
                continue;
            std::cout << _fd_array[i] << " ";
        }
        std::cout << "\r\n";
    }
    void Stop()
    {
        _isrunning = false;
    }
    ~SelectServer()
    {
    }

private:
    std::unique_ptr<Socket> _listensock;
    bool _isrunning;
    int _fd_array[size];
};

Main.cc

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]);

    std::unique_ptr<SelectServer> svr = std::make_unique<SelectServer>(port);
    svr->Start();
    
    return 0;
}
相关推荐
爱编码的傅同学2 小时前
【计算机网络】初识网络
网络·计算机网络
晚霞的不甘2 小时前
Flutter for OpenHarmony 打造沉浸式呼吸引导应用:用动画疗愈身心
服务器·网络·flutter·架构·区块链
CHENKONG_CK2 小时前
化工危化品桶装追溯:RFID 全流程可视化解决方案
网络
JMchen1232 小时前
Android UDP编程:实现高效实时通信的全面指南
android·经验分享·网络协议·udp·kotlin
临水逸3 小时前
一次路径穿越漏洞引发的NAS安全危机:飞牛fnOS漏洞深度剖析与用户自救指南
网络·安全·web安全
强风7943 小时前
Linux-传输层协议TCP
linux·网络·tcp/ip
狮驼岭的小钻风3 小时前
汽车V模型开发流程、ASPICE、汽车功能安全的基石是国际标准 ISO 26262
网络·安全·汽车
崎岖Qiu3 小时前
【计算机网络 | 第十篇】以太网的 MAC 层
网络·笔记·计算机网络·mac地址
looking_for__3 小时前
【Linux】应用层自定义协议与序列化
linux·服务器·网络