18.【网络】多路转接select系统调用详解 - 修正版

初识 select:Linux I/O 多路复用(C++ / Linux 笔试面试版)

学习目标:说清 select 是什么、为什么需要它、五个参数和返回值、fd_set 的输入输出特性、Socket 就绪条件,并能够写出一个监视多个客户端的 TCP 服务器。

前置知识:文件描述符(fd)、socket/bind/listen/accept、recv、阻塞与非阻塞 I/O。

目录

  • [初识 `select`:Linux I/O 多路复用(C++ / Linux 笔试面试版)](#初识 select:Linux I/O 多路复用(C++ / Linux 笔试面试版))
    • [1. 什么是 `select`?](#1. 什么是 select?)
      • [1.1 概念与作用](#1.1 概念与作用)
      • [1.2 它解决了什么问题?](#1.2 它解决了什么问题?)
    • [2. `select()` 函数原型、参数与返回值](#2. select() 函数原型、参数与返回值)
      • [2.1 函数原型](#2.1 函数原型)
      • [2.2 五个参数](#2.2 五个参数)
      • [2.3 `timeout` 的三种设置](#2.3 timeout 的三种设置)
      • [2.4 返回值](#2.4 返回值)
    • [3. `fd_set`:为什么要用位图?](#3. fd_set:为什么要用位图?)
      • [3.1 `fd_set` 的含义](#3.1 fd_set 的含义)
      • [3.2 用 8 位的简化位图理解执行过程](#3.2 用 8 位的简化位图理解执行过程)
      • [3.3 为什么每次调用前都要重建集合?](#3.3 为什么每次调用前都要重建集合?)
      • [3.4 `FD_SETSIZE` 与 fd 上限](#3.4 FD_SETSIZE 与 fd 上限)
    • [4. Socket 什么时候算"就绪"?](#4. Socket 什么时候算“就绪”?)
      • [4.1 读就绪(`readfds`)](#4.1 读就绪(readfds))
      • [4.2 写就绪(`writefds`)](#4.2 写就绪(writefds))
      • [4.3 异常就绪(`exceptfds`,了解即可)](#4.3 异常就绪(exceptfds,了解即可))
      • [4.4 低水位(了解即可)](#4.4 低水位(了解即可))
      • [4.5 就绪之后调用 I/O,还可能阻塞吗?](#4.5 就绪之后调用 I/O,还可能阻塞吗?)
    • [5. `select` 的标准使用流程](#5. select 的标准使用流程)
    • [6. `select` 的优点、局限与 `epoll` 对比](#6. select 的优点、局限与 epoll 对比)
      • [`select`、`poll`、`epoll` 的面试速记](#select、poll、epoll 的面试速记)
    • [7. 代码示例一:监视标准输入(可独立编译)](#7. 代码示例一:监视标准输入(可独立编译))
    • [8. 代码示例二:`select` 管理多个 TCP 客户端(核心实战)](#8. 代码示例二:select 管理多个 TCP 客户端(核心实战))
      • [8.1 服务器设计思路](#8.1 服务器设计思路)
      • [8.2 完整 C++17 示例:`select_server.cpp`](#8.2 完整 C++17 示例:select_server.cpp)
      • [8.3 代码中必须看懂的几个点](#8.3 代码中必须看懂的几个点)
    • [9. 高频易错点与笔试面试题](#9. 高频易错点与笔试面试题)
      • [9.1 10 个易错点](#9.1 10 个易错点)
      • [9.2 高频面试问答](#9.2 高频面试问答)
      • [9.3 一段话背诵版](#9.3 一段话背诵版)
    • [10. 学习清单(考前快速自测)](#10. 学习清单(考前快速自测))

1. 什么是 select?

1.1 概念与作用

select 是一种 I/O 多路复用(也叫 I/O 多路转接)接口 :一个线程可以通过一次系统调用,等待多个文件描述符(fd)是否满足可读、可写或异常就绪条件,然后处理已经就绪的 fd。

  • 多路:多个 fd,例如监听 Socket、多个客户端 Socket、管道或终端输入。
  • 复用:同一个线程复用自己的等待与处理能力,而不是为每个 fd 都安排一个专门等待的线程。
  • 就绪 :某种 I/O 操作现在可能取得进展;不代表业务消息完整到达,也不代表 I/O 已经由 select 完成。

大白话理解: 一名服务员管理多桌顾客,不必站在某一桌干等;他先统一观察哪些桌需要服务,再去对应的桌子处理。select 做的是通知"谁可以处理" ,真正读取/发送数据仍由 read/recv/write/send 等完成。

1.2 它解决了什么问题?

  • 如果一个线程只对一个阻塞 Socket 调用 recv(),在没有数据时,线程就会卡在这个 Socket 上。
  • select() 可以同时等待多个 Socket:哪个就绪,就处理哪个。
  • 因此它适合做小型 TCP 服务器,也是理解 poll、epoll 的基础。

易错点: select 判断的是 I/O 就绪状态 ,不是必须"由不可读变成可读"的状态变化 。如果一个 fd 上还有未读数据,下次调用 select 仍可能立即报告可读。


2. select() 函数原型、参数与返回值

2.1 函数原型

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

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

2.2 五个参数

参数 含义 记忆方法
nfds 所有被监视 fd 中的最大值 + 1 ;内核检查 fd 编号范围 [0, nfds) 检查到哪里
readfds 输入要监视读就绪的 fd;返回后只保留读就绪的 fd 谁能读
writefds 输入要监视写就绪的 fd;返回后只保留写就绪的 fd 谁能写
exceptfds 输入要监视异常条件的 fd;主要用于 TCP 带外数据等特殊情形 特殊异常条件
timeout 最长等待时间;控制 select() 本次调用要等多久 等多久

说明:

  1. 不需要监视的集合参数可传 nullptr(C 代码也常写 NULL)。
  2. 一个 fd 可以同时放在读集合和写集合中。
  3. 三个 fd_set 是输入输出参数:调用前表示"关心谁",调用后表示"谁已就绪"。
  4. nfds 不是 fd 的总数。例如只监视 fd=3 和 fd=8,也要传 9,不是 2。

2.3 timeout 的三种设置

cpp 复制代码
struct timeval {
    long tv_sec;   // 秒
    long tv_usec;  // 微秒,合法范围通常是 0~999999
};
timeout 行为
nullptr 一直等待,直到某个条件就绪或被信号中断等情况发生
{0, 0} 立即检查并返回,不等待
{2, 0} 最多等待约 2 秒;提前就绪就提前返回

大白话理解: nullptr 是"有人准备好再叫我";零超时是"我看一眼,没事马上走";正数超时是"最多等两秒"。

特别区分: timeout={0,0} 只表示 select() 不等待 ,不会自动把被监视的 Socket 设置为 O_NONBLOCK。Socket 本身是否阻塞,要单独设置。

Linux 注意: select() 可能修改传入的 timeval,因此重复调用时若想使用相同超时,应重新赋值;可用 pselect() 等接口处理更复杂的超时与信号需求。

2.4 返回值

返回值 意义 程序应该做什么
> 0 本次检测中,三个返回集合内就绪位的总数 用 FD_ISSET() 检查具体 fd
0 本次等待超时,未发现所监视的条件就绪 按需求继续下一轮
-1 失败,错误码保存在 errno 判断错误并处理

注意: 如果同一个 fd 同时在读、写集合里就绪,返回值可能对其计数两次,因此不能简单理解为"不同 fd 的个数"。

常见 errno:

  • EINTR:等待被信号中断;通常先检查是否需要退出,否则重新构造集合并重试。
  • EBADF:集合中包含无效或已关闭的 fd。
  • EINVAL:参数不合法,例如 nfds 为负数、tv_usec 越界等。
  • ENOMEM:内存不足。

实战注意: select() 失败时,不要依赖传入集合的内容;下一次调用前重新准备集合和超时时间。


3. fd_set:为什么要用位图?

3.1 fd_set 的含义

fd_set 是专门配合 select() 使用的文件描述符集合类型 。可以把它理解为一张位图:

  • 第 0 位对应 fd=0,第 1 位对应 fd=1,依此类推。
  • 位是 1:在输入集合中表示"需要监视",在输出集合中表示"已就绪"。
  • 位是 0:在输入集合中表示"不监视",在输出集合中表示"未就绪或未被监视"。

无需直接访问它的内部数组,应通过下面四个宏操作(不是普通函数):

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

FD_ZERO(&set);     // 清空所有位
FD_SET(fd, &set);  // 把某个 fd 加入集合
FD_CLR(fd, &set);  // 从集合移除某个 fd
FD_ISSET(fd, &set); // 判断对应位是否为 1,结果为非零/零

3.2 用 8 位的简化位图理解执行过程

假设位图只有 8 位 (仅为举例),要监视 fd=1、2、5 的读就绪。

text 复制代码
位号:       7 6 5 4 3 2 1 0
初始化:     0 0 0 0 0 0 0 0
加入 1,2,5: 0 0 1 0 0 1 1 0   = 0010 0110

此时最大 fd 为 5,调用 select(6, &readfds, nullptr, nullptr, nullptr)。

假设 fd=1、2 可读而 fd=5 不可读,那么 select() 返回后:

text 复制代码
位号:       7 6 5 4 3 2 1 0
返回集合:   0 0 0 0 0 1 1 0   = 0000 0110

返回值是 2;随后 FD_ISSET(1, &readfds) 和 FD_ISSET(2, &readfds) 为真,而 FD_ISSET(5, &readfds) 为假。

大白话理解: 调用前,位图是一张待检查名单 ;调用后,名单被改写成本轮检查合格名单 。所以不能把返回的集合当成下一轮完整的待检查名单。

3.3 为什么每次调用前都要重建集合?

假设本轮监视 1、2、5,结果只有 1、2 就绪;返回后,集合中的 5 被清零。如果直接拿这个集合进入下一轮,就不再监视 fd=5 了。

常见写法有两种:

方式 A:保留原始集合,每轮复制一份。

cpp 复制代码
fd_set master;
FD_ZERO(&master);
FD_SET(fd1, &master);
FD_SET(fd2, &master);

for (;;) {
    fd_set ready = master; // select 会修改 ready,master 保持不变
    int ret = select(maxfd + 1, &ready, nullptr, nullptr, nullptr);
    // 检查 ret,并通过 FD_ISSET 检查 ready
}

方式 B:用数组或容器保存全部活动 fd,每轮 FD_ZERO + FD_SET 重建。

服务器需要不断新增、关闭客户端 Socket,方式 B 很直观;也可以维护一份随连接变化而更新的 master 集合。

3.4 FD_SETSIZE 与 fd 上限

  • Linux 常见的 glibc fd_set 容量是 1024 个描述符位 ,即 fd 可取 0~1023。
  • 需要检查的是 fd 编号,而不只是连接数量 。即使只监视 10 个 fd,只要某个 fd >= FD_SETSIZE,也不能安全地对这个 fd_set 执行 FD_SET/FD_ISSET。
  • 这是常见 fd_set 接口的容量限制,不是 Linux 内核所有 I/O 多路复用机制的统一上限,也不是简单"重编内核即可调整"。
  • Linux 高并发网络服务一般更倾向使用 epoll。

原文相关图示,可配合上面的位图例子理解:


4. Socket 什么时候算"就绪"?

本节讨论典型 TCP Socket。就绪不是事件一定"刚发生",而是此刻对应操作有机会不必等待就取得结果。它可能成功、返回 EOF,也可能报告错误。

4.1 读就绪(readfds)

常见情况:

  1. 接收缓冲区有可读取的数据,例如客户端发来了消息。
  2. 对端有序关闭发送方向 ,所有已接收数据读完后,recv() 返回 0(TCP EOF)。
  3. 监听 Socket 已有待接受连接 ,可以尝试 accept()。
  4. Socket 存在待处理错误,也可能表现为读就绪,读取时返回错误。

大白话理解: "可读"不一定表示"收到新消息",也可能表示"对方已经正常结束发送,现在可以读到结束标志"。

监听 fd 的"读就绪"不是收到业务数据,而是有连接可以接受 。accept() 取出内核已经建立的连接,三次握手不是它执行的。

4.2 写就绪(writefds)

常见情况:

  1. 发送缓冲区具备可写空间 ,可以尝试 send();普通空闲 TCP Socket 往往长期写就绪。
  2. 非阻塞 connect() 完成(成功或失败) ,常表现为写就绪,必须再用 getsockopt(..., SOL_SOCKET, SO_ERROR, ...) 确认结果。
  3. Socket 出错或连接状态异常时,也可能出现相应就绪通知,需要检查后续系统调用结果。

特别注意 TCP 半关闭: 对端发送 FIN 只表示对端不再向你发送数据 ,并不代表你马上不能继续向对端发送数据;如果连接已重置或本端写入已关闭,send() 则可能失败(例如 EPIPE,还可能伴随 SIGPIPE)。

重点:写就绪 ≠ 能一次发送完全部数据。 send() 可能只发送一部分,因此要处理部分写入 ;非阻塞 Socket 还要处理 EAGAIN/EWOULDBLOCK。

大白话理解: 写就绪表示"快递站还有地方收件",不保证一次能把十箱货全收下。

性能提示: 不要长期把所有正常连接都放进 writefds,否则它们经常一直可写,select() 可能不断立即返回。实际服务器通常在确实有待发送数据时才监视写就绪。

4.3 异常就绪(exceptfds,了解即可)

主要面向特殊异常条件 ,典型例子是 TCP 带外/紧急数据。它并不是"所有 Socket 错误的通用报警集合" ;一般连接错误仍需结合 recv/send/getsockopt(SO_ERROR) 处理。

4.4 低水位(了解即可)

  • SO_RCVLOWAT:接收低水位,影响"多少数据可读时才算读就绪";Linux 默认通常为 1 字节。
  • SO_SNDLOWAT:发送低水位,影响写就绪的最小空间要求;Linux 上该选项不能通过 setsockopt() 修改,不要把某个固定"1024 字节"当成通用标准。

大白话理解: 低水位类似"够多少水才提醒我取水"的门槛,但实际是否能一次读写完,还得看调用返回值。

4.5 就绪之后调用 I/O,还可能阻塞吗?

可能。 例如就绪通知与真正调用 accept/recv 之间,其他线程也可能消费掉数据或连接;读取/发送量超过当前可用量也可能导致问题。

因此,高可靠网络事件循环通常把 Socket 设置为 O_NONBLOCK 。即使收到就绪通知,具体的 accept/recv/send 仍要正确处理 EAGAIN/EWOULDBLOCK。


5. select 的标准使用流程

#mermaid-svg-AYechr6GZANmyCbR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AYechr6GZANmyCbR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AYechr6GZANmyCbR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AYechr6GZANmyCbR .error-icon{fill:#552222;}#mermaid-svg-AYechr6GZANmyCbR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AYechr6GZANmyCbR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AYechr6GZANmyCbR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AYechr6GZANmyCbR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AYechr6GZANmyCbR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AYechr6GZANmyCbR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AYechr6GZANmyCbR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AYechr6GZANmyCbR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AYechr6GZANmyCbR .marker.cross{stroke:#333333;}#mermaid-svg-AYechr6GZANmyCbR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AYechr6GZANmyCbR p{margin:0;}#mermaid-svg-AYechr6GZANmyCbR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AYechr6GZANmyCbR .cluster-label text{fill:#333;}#mermaid-svg-AYechr6GZANmyCbR .cluster-label span{color:#333;}#mermaid-svg-AYechr6GZANmyCbR .cluster-label span p{background-color:transparent;}#mermaid-svg-AYechr6GZANmyCbR .label text,#mermaid-svg-AYechr6GZANmyCbR span{fill:#333;color:#333;}#mermaid-svg-AYechr6GZANmyCbR .node rect,#mermaid-svg-AYechr6GZANmyCbR .node circle,#mermaid-svg-AYechr6GZANmyCbR .node ellipse,#mermaid-svg-AYechr6GZANmyCbR .node polygon,#mermaid-svg-AYechr6GZANmyCbR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AYechr6GZANmyCbR .rough-node .label text,#mermaid-svg-AYechr6GZANmyCbR .node .label text,#mermaid-svg-AYechr6GZANmyCbR .image-shape .label,#mermaid-svg-AYechr6GZANmyCbR .icon-shape .label{text-anchor:middle;}#mermaid-svg-AYechr6GZANmyCbR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AYechr6GZANmyCbR .rough-node .label,#mermaid-svg-AYechr6GZANmyCbR .node .label,#mermaid-svg-AYechr6GZANmyCbR .image-shape .label,#mermaid-svg-AYechr6GZANmyCbR .icon-shape .label{text-align:center;}#mermaid-svg-AYechr6GZANmyCbR .node.clickable{cursor:pointer;}#mermaid-svg-AYechr6GZANmyCbR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AYechr6GZANmyCbR .arrowheadPath{fill:#333333;}#mermaid-svg-AYechr6GZANmyCbR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AYechr6GZANmyCbR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AYechr6GZANmyCbR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AYechr6GZANmyCbR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AYechr6GZANmyCbR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AYechr6GZANmyCbR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AYechr6GZANmyCbR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AYechr6GZANmyCbR .cluster text{fill:#333;}#mermaid-svg-AYechr6GZANmyCbR .cluster span{color:#333;}#mermaid-svg-AYechr6GZANmyCbR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AYechr6GZANmyCbR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AYechr6GZANmyCbR rect.text{fill:none;stroke-width:0;}#mermaid-svg-AYechr6GZANmyCbR .icon-shape,#mermaid-svg-AYechr6GZANmyCbR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AYechr6GZANmyCbR .icon-shape p,#mermaid-svg-AYechr6GZANmyCbR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AYechr6GZANmyCbR .icon-shape .label rect,#mermaid-svg-AYechr6GZANmyCbR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AYechr6GZANmyCbR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AYechr6GZANmyCbR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AYechr6GZANmyCbR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 大于 0
监听 fd
通信 fd
等于 0
EINTR
其他错误
创建监听 Socket / 客户端 fd
保存当前所有需要监视的 fd
每轮构造 readfds / writefds
计算 maxfd + 1
调用 select 等待就绪
返回值
用 FD_ISSET 找到就绪 fd
fd 类型
accept 接受新连接
recv / send 处理数据
更新 fd 列表
处理错误 / 退出

把代码设计分成三块更容易理解:

  • Accepter(连接处理):监听 fd 就绪后,接受客户端连接,保存新的 fd。
  • Dispatcher(事件分发):判断哪个 fd 就绪,把它交给正确的处理逻辑。
  • Recver(数据处理):对客户端 fd 读取数据;断开时关闭并从监视列表移除。

大白话理解: 监听 Socket 是公司前台负责接待新客人;客户端 Socket 是已经接待的客人;Dispatcher 负责把前台消息和客人消息分别交给对应人员。


6. select 的优点、局限与 epoll 对比

方面 select 理解重点
优点 接口经典,支持多个 fd 就绪等待;多种 Unix-like 系统可用 适合学习与适量并发
上限 常见 fd_set 限制 FD_SETSIZE=1024 fd 编号不能越界
集合管理 每轮传入的集合会被改写 保存主集合,或重建集合
系统调用开销 每轮需要传递集合,内核扫描被监视范围 fd 多时成本增加
就绪事件获取 程序通常要遍历已保存的 fd,调用 FD_ISSET 不会直接返回"就绪 fd 列表"
跨平台 Unix-like 系统广泛支持,Windows 接口细节不同 epoll 是 Linux 特有

select、poll、epoll 的面试速记

接口 典型特点
select 使用位图 fd_set,有固定容量,调用前要准备、返回后要解析集合
poll 使用 pollfd 数组,没有 fd_set 的固定容量限制,但通常仍需要扫描数组
epoll Linux 专用,通过 epoll_ctl 管理关注集合、epoll_wait 取就绪事件;适合大量连接

不要机械记忆"epoll 一定快" :性能取决于连接数量、活跃比例、业务处理成本和实际环境。select 也能够正确实现多连接服务。


7. 代码示例一:监视标准输入(可独立编译)

下面的程序一直等待标准输入,有内容就读出并显示;遇到 EOF 正常退出。它只监视 fd=0,用于理解最简单的 select 工作方式。

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

int main() {
    for (;;) {
        fd_set rfds;
        FD_ZERO(&rfds);             // 每轮重新准备集合
        FD_SET(STDIN_FILENO, &rfds);

        int ret = select(STDIN_FILENO + 1, &rfds,
                         nullptr, nullptr, nullptr);
        if (ret < 0) {
            if (errno == EINTR) continue;
            perror("select");
            return 1;
        }

        if (FD_ISSET(STDIN_FILENO, &rfds)) {
            char buf[1024];
            ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
            if (n > 0) {
                printf("input: ");
                fwrite(buf, 1, static_cast<size_t>(n), stdout);
                fflush(stdout);
            } else if (n == 0) {
                puts("EOF, exit"); // 避免 EOF 后 select 反复立即就绪
                break;
            } else if (errno != EINTR) {
                perror("read");
                return 1;
            }
        }
    }
    return 0;
}

编译运行:

bash 复制代码
g++ -std=c++17 -Wall -Wextra stdin_select.cpp -o stdin_select
./stdin_select

注意: 本例 timeout=nullptr,所以没有输入时会一直等待,不会自动产生超时提示 。如果输入流到达 EOF,fd 也可能持续处于可读状态,程序应主动处理 read()==0。


8. 代码示例二:select 管理多个 TCP 客户端(核心实战)

8.1 服务器设计思路

与原来 SelectServer 的设计一致,仍然分成三类职责:

  1. Start():构造读集合,计算最大 fd,调用 select()。
  2. Dispatcher():对就绪 fd 分类处理。
  3. Accepter() / Recver():分别接收新连接、读取客户端消息。

为什么需要保存全部客户端 fd? 因为 select 会改写临时读集合,而且新连接不断加入、旧连接不断退出;需要一份独立的当前活动连接列表,用来构造下一轮监视集合。

下面是不依赖额外 Socket.hpp/Log.hpp 的完整示例。为突出 select 的核心流程,它只把收到的内容打印到服务端终端,并不把内容回发给客户端(因此是消息接收服务器,不是 Echo 回显服务器)。

8.2 完整 C++17 示例:select_server.cpp

cpp 复制代码
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <vector>

#include <fcntl.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

class SelectServer {
public:
    ~SelectServer() {
        for (int fd : clients_) close(fd);
        if (listen_fd_ >= 0) close(listen_fd_);
    }

    bool Init(int port) {
        listen_fd_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listen_fd_ < 0) { perror("socket"); return false; }

        int yes = 1;
        if (setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR,
                       &yes, sizeof(yes)) < 0) {
            perror("setsockopt"); return false;
        }

        sockaddr_in addr{};
        addr.sin_family = AF_INET;
        addr.sin_port = htons(static_cast<uint16_t>(port));
        addr.sin_addr.s_addr = htonl(INADDR_ANY);
        if (bind(listen_fd_, reinterpret_cast<sockaddr*>(&addr),
                 sizeof(addr)) < 0) {
            perror("bind"); return false;
        }
        if (listen(listen_fd_, 128) < 0) {
            perror("listen"); return false;
        }
        if (listen_fd_ >= FD_SETSIZE || !SetNonBlocking(listen_fd_)) {
            std::cerr << "listen fd invalid or nonblocking setup failed\n";
            return false;
        }
        std::cout << "listening on port " << port << '\n';
        return true;
    }

    void Start() {
        for (;;) {
            fd_set rfds;
            FD_ZERO(&rfds);
            FD_SET(listen_fd_, &rfds);
            int maxfd = listen_fd_;

            for (int fd : clients_) {
                FD_SET(fd, &rfds);
                maxfd = std::max(maxfd, fd);
            }

            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            if (n < 0) {
                if (errno == EINTR) continue;
                perror("select");
                return;
            }
            Dispatcher(rfds);
        }
    }

private:
    static bool SetNonBlocking(int fd) {
        int flags = fcntl(fd, F_GETFL);
        return flags >= 0 &&
               fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0;
    }

    // 处理 select 返回时就已存在的客户端;随后接收新连接。
    void Dispatcher(fd_set& rfds) {
        for (auto it = clients_.begin(); it != clients_.end();) {
            int fd = *it;
            if (FD_ISSET(fd, &rfds) && !Recver(fd)) {
                close(fd);
                it = clients_.erase(it);
            } else {
                ++it;
            }
        }
        if (FD_ISSET(listen_fd_, &rfds)) Accepter();
    }

    // 监听 fd 可读:说明有待 accept 的连接。
    void Accepter() {
        for (;;) {
            int fd = accept(listen_fd_, nullptr, nullptr);
            if (fd < 0) {
                if (errno == EINTR) continue;
                if (errno == EAGAIN || errno == EWOULDBLOCK) return;
                perror("accept");
                return;
            }
            if (fd >= FD_SETSIZE || !SetNonBlocking(fd)) {
                std::cerr << "reject fd " << fd << '\n';
                close(fd);
                continue;
            }
            clients_.push_back(fd);
            std::cout << "client connected, fd=" << fd << '\n';
        }
    }

    // 通信 fd 可读:读取数据;返回 false 表示需关闭连接。
    bool Recver(int fd) {
        char buf[4096];
        for (;;) {
            ssize_t n = recv(fd, buf, sizeof(buf), 0);
            if (n > 0) {
                std::cout << "fd=" << fd << " says: ";
                std::cout.write(buf, n); // 按实际字节数打印,不假定有 '\0'
                std::cout << '\n';
            } else if (n == 0) {
                std::cout << "client closed, fd=" << fd << '\n';
                return false;
            } else if (errno == EINTR) {
                continue;
            } else if (errno == EAGAIN || errno == EWOULDBLOCK) {
                return true;          // 当前数据读完,等待下轮就绪
            } else {
                perror("recv");
                return false;
            }
        }
    }

    int listen_fd_ = -1;
    std::vector<int> clients_; // 当前所有活动客户端 fd
};

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " <port>\n";
        return 1;
    }
    char* end = nullptr;
    long port = std::strtol(argv[1], &end, 10);
    if (end == argv[1] || *end != '\0' || port < 1 || port > 65535) {
        std::cerr << "invalid port\n";
        return 1;
    }

    SelectServer server;
    if (!server.Init(static_cast<int>(port))) return 1;
    server.Start();
    return 0;
}

编译与测试:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -O2 select_server.cpp -o selectserver
./selectserver 8080

# 另开一个或多个终端(安装了 nc 或 telnet 后)
nc 127.0.0.1 8080
# 或者:telnet 127.0.0.1 8080

客户端输入内容后,服务端终端打印 收到的字节;示例没有调用 send(),所以客户端不会收到"回显"。

8.3 代码中必须看懂的几个点

代码 为什么这样写?
FD_ZERO + FD_SET 每轮重建监视集合,否则上次未就绪的 fd 可能丢失
maxfd + 1 nfds 要覆盖最大的 fd 编号
listen_fd_ 加入 rfds 监听新客户端连接,本质也是"可读就绪"
客户端 fd 加入 rfds 监视已建立连接是否有数据或关闭等读事件
Dispatcher 区分"新连接"与"老连接收到数据"
clients_ 容器 保存当前活动连接,供下一轮重建集合
O_NONBLOCK 避免实际 accept/recv 意外阻塞整个事件循环
读到 EAGAIN 代表这轮暂时没有更多数据,返回事件循环
recv()==0 TCP 对端已正常关闭发送方向,按本示例关闭连接
fd >= FD_SETSIZE 避免 FD_SET 超出位图容量,导致未定义行为

边界说明: 这个示例侧重说明 select 事件分发,不是生产级服务器。真正的高并发服务器还要考虑连接超时、发送缓冲、部分写、TCP 字节流消息边界、限流、优雅退出等。


9. 高频易错点与笔试面试题

9.1 10 个易错点

  1. select 监视的是就绪状态,不要求出现"状态变化"。
  2. 就绪不等于读完/写完 ;实际 I/O 仍由 recv/send 等执行。
  3. nfds = maxfd + 1,不是集合里 fd 的数量。
  4. select 会改写 传入的三个 fd_set,下一轮必须重建或重新复制。
  5. select 返回的正数是三个结果集合中就绪位的总数,不总等于不同 fd 的个数。
  6. timeout=nullptr 表示一直等;timeout={0,0} 才是立即返回。
  7. select 的等待方式不等于 Socket 自身的阻塞/非阻塞属性。
  8. 监听 Socket 可读 通常表示有待接受连接;通信 Socket 可读可能是数据、EOF 或错误。
  9. 可写不代表全部待发字节一定能一次写完;也不要无条件长期监听所有连接的可写事件。
  10. 常见 Linux fd_set 只能安全处理 fd < FD_SETSIZE 的编号,不能用 FD_SET(1024, ...) (当 FD_SETSIZE==1024 时)。

9.2 高频面试问答

Q1:什么是 I/O 多路复用?select 如何实现?

A:一个线程同时等待多个 fd 的就绪条件。select 将监视集合交给内核,内核返回就绪集合,程序再调用 accept/recv/send 处理具体 I/O。

Q2:select 的 nfds 为什么要加 1?

A:检查范围是从 fd=0 到 nfds-1,所以需要传最大 fd + 1。

Q3:为什么每次调用 select 前要准备 fd_set?

A:集合是输入输出参数,返回后只保留本轮就绪的位,原监视名单会被改写。

Q4:select 能同时监听多个客户端吗?

A:能,监听 Socket 用于接收新连接,已连接的客户端 Socket 用于接收消息;由一个事件循环统一管理。

Q5:为什么监听 Socket 也有读就绪?

A:它的读就绪通常表示已完成连接队列中存在待接受连接,可以调用 accept()。

Q6:select 返回可读,recv() 就不会阻塞吗?

A:不能做绝对保证;就绪条件可能被其他线程消耗,建议事件驱动程序配合非阻塞 fd 并处理 EAGAIN。

Q7:recv() 返回 0 代表什么?

A:对 TCP 流 Socket 来说,表示读到 EOF,即对端已关闭发送方向,且已有数据已读完。

Q8:select 为什么不太适合特别多的连接?

A:位图容量有限;每次调用要传递/处理集合,并扫描描述符;大规模高并发常改用 epoll。

Q9:select 是异步 I/O 吗?

A:不是。它是同步 I/O 的就绪通知机制 ;真正的数据读取/复制仍由调用 recv/read 的线程完成。

Q10:select 能保证 TCP 解决粘包吗?

A:不能。select 只告诉你 Socket 是否可读;TCP 是字节流,仍要靠应用层协议(定长、分隔符、长度字段等)判断消息边界。

9.3 一段话背诵版

select 是 Linux/Unix 中经典的 I/O 多路复用接口,可以通过一个线程同时等待多个文件描述符的可读、可写和异常就绪条件。它使用 fd_set 记录需要监视的 fd,调用时传入最大 fd 加 1 和超时时间;返回后 fd_set 被修改为就绪集合,程序通过 FD_ISSET 找到对应 fd,再调用 accept/recv/send 处理 I/O。其缺点是集合容量限制、需要重复准备集合以及扫描描述符的开销,因此高并发 Linux 网络服务器通常更常用 epoll。


10. 学习清单(考前快速自测)

  • 能解释 I/O 多路复用和"就绪 ≠ 完成"。
  • 能写出 select() 函数原型,解释五个参数与三个返回值。
  • 能口算给定多个 fd 的 nfds,并正确读写 fd_set 位图。
  • 能解释为什么 fd_set 每轮需要重建。
  • 能分别说出监听 Socket、普通 TCP Socket 读就绪的含义。
  • 能解释 recv()==0、EINTR、EAGAIN/EWOULDBLOCK。
  • 能口述 Start → Dispatcher → Accepter/Recver 的处理流程。
  • 能说明 select 的限制,以及与 poll/epoll 的主要区别。

原始学习参考: Linux 网络多路转接 select 的使用代码资料(本文件中的可编译示例已改为独立实现,无需原参考代码的额外头文件)。

相关推荐
资深电气设计3 小时前
明治MLD25系列激光位移传感器技术解析:3C行业零件厚度测量方案参考
网络
hz567893 小时前
数据本地化音视频系统设计:数据存储、传输路径与访问权限管理
网络·安全·音视频·实时音视频·信息与通信
笨手笨脚の4 小时前
从一行 ip route 到容器跨主机通信
网络·网络协议·tcp/ip
mmsx4 小时前
Android 模块化三件套:轻量组件化、宿主插件双形态、AIDL 隔离
android·网络·组件化·aidl
czt_java4 小时前
网络基础入门:IPv4、IPv6、私网公网、MAC、以太网帧与 DNS 学习笔记
网络·学习·macos
是个西兰花4 小时前
http讲解
网络·网络协议·http
tianbin9114 小时前
中小团队大模型训练落地实战:如何借助API网关降低工程负担
网络·后端
2401_868534784 小时前
VXLAN虚拟可扩展局域网
服务器·网络
烟雨醉沉浮4 小时前
VLAN 网络划分与安全管控
网络·安全·智能路由器