初识 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 上限)
- [3.1 `fd_set` 的含义](#3.1
- [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,还可能阻塞吗?)
- [4.1 读就绪(`readfds`)](#4.1 读就绪(
- [5. `select` 的标准使用流程](#5.
select的标准使用流程) - [6. `select` 的优点、局限与 `epoll` 对比](#6.
select的优点、局限与epoll对比) -
- [`select`、`poll`、`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?
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() 本次调用要等多久 |
等多久 |
说明:
- 不需要监视的集合参数可传
nullptr(C 代码也常写NULL)。 - 一个 fd 可以同时放在读集合和写集合中。
- 三个
fd_set是输入输出参数:调用前表示"关心谁",调用后表示"谁已就绪"。 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)
常见情况:
- 接收缓冲区有可读取的数据,例如客户端发来了消息。
- 对端有序关闭发送方向 ,所有已接收数据读完后,
recv()返回0(TCP EOF)。 - 监听 Socket 已有待接受连接 ,可以尝试
accept()。 - Socket 存在待处理错误,也可能表现为读就绪,读取时返回错误。
大白话理解: "可读"不一定表示"收到新消息",也可能表示"对方已经正常结束发送,现在可以读到结束标志"。
监听 fd 的"读就绪"不是收到业务数据,而是有连接可以接受 。
accept()取出内核已经建立的连接,三次握手不是它执行的。
4.2 写就绪(writefds)
常见情况:
- 发送缓冲区具备可写空间 ,可以尝试
send();普通空闲 TCP Socket 往往长期写就绪。 - 非阻塞
connect()完成(成功或失败) ,常表现为写就绪,必须再用getsockopt(..., SOL_SOCKET, SO_ERROR, ...)确认结果。 - 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 的设计一致,仍然分成三类职责:
Start():构造读集合,计算最大 fd,调用select()。Dispatcher():对就绪 fd 分类处理。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 个易错点
select监视的是就绪状态,不要求出现"状态变化"。- 就绪不等于读完/写完 ;实际 I/O 仍由
recv/send等执行。 nfds = maxfd + 1,不是集合里 fd 的数量。select会改写 传入的三个fd_set,下一轮必须重建或重新复制。select返回的正数是三个结果集合中就绪位的总数,不总等于不同 fd 的个数。timeout=nullptr表示一直等;timeout={0,0}才是立即返回。select的等待方式不等于 Socket 自身的阻塞/非阻塞属性。- 监听 Socket 可读 通常表示有待接受连接;通信 Socket 可读可能是数据、EOF 或错误。
- 可写不代表全部待发字节一定能一次写完;也不要无条件长期监听所有连接的可写事件。
- 常见 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 的使用代码资料(本文件中的可编译示例已改为独立实现,无需原参考代码的额外头文件)。