Linux网络编程:彻底理解IO多路复用select!从fd_set位图、读写就绪到手写TCP多客户端字典服务器

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《Linux网络编程从0到1》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能
文章目录
- Linux网络编程:彻底理解IO多路复用select!从fd_set位图、读写就绪到手写TCP多客户端字典服务器
- 一、什么是I/O多路转接?
-
- [1.1 普通阻塞I/O的问题](#1.1 普通阻塞I/O的问题)
- [1.2 非阻塞轮询的改进与问题](#1.2 非阻塞轮询的改进与问题)
- [1.3 select的解决方案](#1.3 select的解决方案)
- [1.4 select是不是异步I/O?](#1.4 select是不是异步I/O?)
- [1.5 select一定不会阻塞吗?](#1.5 select一定不会阻塞吗?)
- 二、select函数原型
- 三、第一个参数nfds是什么意思?
-
- [3.1 为什么不是文件描述符数量?](#3.1 为什么不是文件描述符数量?)
- [3.2 为什么要加1?](#3.2 为什么要加1?)
- [3.3 另一个例子](#3.3 另一个例子)
- 四、readfds、writefds、exceptfds分别是什么?
-
- [4.1 readfds:读就绪集合](#4.1 readfds:读就绪集合)
- [4.2 writefds:写就绪集合](#4.2 writefds:写就绪集合)
- [4.3 exceptfds:异常条件集合](#4.3 exceptfds:异常条件集合)
- [4.4 如果只需要监视读事件怎么办?](#4.4 如果只需要监视读事件怎么办?)
- 五、第五个参数timeout是什么意思?
-
- [5.1 timeval结构](#5.1 timeval结构)
- [5.2 第一种情况:timeout为NULL](#5.2 第一种情况:timeout为NULL)
- [5.3 第二种情况:timeout为0](#5.3 第二种情况:timeout为0)
- [5.4 第三种情况:指定超时时间](#5.4 第三种情况:指定超时时间)
- [5.5 三种模式总结](#5.5 三种模式总结)
- 六、什么是fd_set?
-
- [6.1 fd_set可以理解为位图](#6.1 fd_set可以理解为位图)
- [6.2 为什么使用位图?](#6.2 为什么使用位图?)
- [6.3 fd_set是不是一个普通的int数组?](#6.3 fd_set是不是一个普通的int数组?)
- 七、操作fd_set的四个重要宏
-
- [7.1 FD_ZERO:清空集合](#7.1 FD_ZERO:清空集合)
- [7.2 FD_SET:添加描述符](#7.2 FD_SET:添加描述符)
- [7.3 FD_CLR:删除描述符](#7.3 FD_CLR:删除描述符)
- [7.4 FD_ISSET:检查描述符是否在集合中](#7.4 FD_ISSET:检查描述符是否在集合中)
- [7.5 四个宏总结](#7.5 四个宏总结)
- 八、通过位图理解select的执行过程
-
- [8.1 第一步:创建并清空集合](#8.1 第一步:创建并清空集合)
- [8.2 第二步:加入fd5](#8.2 第二步:加入fd5)
- [8.3 第三步:加入fd2和fd1](#8.3 第三步:加入fd2和fd1)
- [8.4 第四步:调用select](#8.4 第四步:调用select)
- [8.5 第五步:假设fd1和fd2就绪](#8.5 第五步:假设fd1和fd2就绪)
- [8.6 最终过程](#8.6 最终过程)
- 九、select会修改fd_set:为什么这是一个大坑?
-
- [9.1 错误写法](#9.1 错误写法)
- [9.2 正确思路一:每轮重新设置](#9.2 正确思路一:每轮重新设置)
- [9.3 正确思路二:保存主集合](#9.3 正确思路二:保存主集合)
- [9.4 为什么这个设计很重要?](#9.4 为什么这个设计很重要?)
- 十、select的返回值
-
- [10.1 返回正数](#10.1 返回正数)
- [10.2 返回0](#10.2 返回0)
- [10.3 返回-1](#10.3 返回-1)
- [10.4 为什么需要处理EINTR?](#10.4 为什么需要处理EINTR?)
- 十一、Socket什么时候算读就绪?
-
- [11.1 第一种:接收缓冲区中有可读取数据](#11.1 第一种:接收缓冲区中有可读取数据)
- [11.2 第二种:TCP对端关闭连接](#11.2 第二种:TCP对端关闭连接)
- [11.3 第三种:监听Socket存在待接受连接](#11.3 第三种:监听Socket存在待接受连接)
- [11.4 第四种:Socket存在待处理错误](#11.4 第四种:Socket存在待处理错误)
- 十二、Socket什么时候算写就绪?
-
- [12.1 发送缓冲区具有可用空间](#12.1 发送缓冲区具有可用空间)
- [12.2 写就绪不意味着可以一次写入任意长度](#12.2 写就绪不意味着可以一次写入任意长度)
- [12.3 非阻塞connect完成以后](#12.3 非阻塞connect完成以后)
- [12.4 关闭写方向与错误情况](#12.4 关闭写方向与错误情况)
- 十三、异常就绪是什么?
- 十四、select的特点
-
- [14.1 可以同时监视多个文件描述符](#14.1 可以同时监视多个文件描述符)
- [14.2 select对描述符编号有上限](#14.2 select对描述符编号有上限)
- [14.3 为什么还需要保存原始描述符集合?](#14.3 为什么还需要保存原始描述符集合?)
- [14.4 为什么还要保存maxfd?](#14.4 为什么还要保存maxfd?)
- 十五、select的缺点
-
- [15.1 缺点一:每次都需要准备描述符集合](#15.1 缺点一:每次都需要准备描述符集合)
- [15.2 缺点二:存在用户态与内核态的数据复制](#15.2 缺点二:存在用户态与内核态的数据复制)
- [15.3 缺点三:内核需要检查描述符集合](#15.3 缺点三:内核需要检查描述符集合)
- [15.4 缺点四:应用程序需要找出就绪描述符](#15.4 缺点四:应用程序需要找出就绪描述符)
- [15.5 缺点五:fd_set存在容量限制](#15.5 缺点五:fd_set存在容量限制)
- [15.6 select、poll、epoll的学习关系](#15.6 select、poll、epoll的学习关系)
- 十六、第一个实验:使用select监听标准输入
-
- [16.1 整个文件要实现什么功能?](#16.1 整个文件要实现什么功能?)
- [16.2 需要设计哪些接口?](#16.2 需要设计哪些接口?)
- [16.3 标准输入的文件描述符](#16.3 标准输入的文件描述符)
- 十七、标准输入实验完整代码
-
- [17.1 编译](#17.1 编译)
- [17.2 运行](#17.2 运行)
- [17.3 为什么每次循环都调用FD_ZERO?](#17.3 为什么每次循环都调用FD_ZERO?)
- [17.4 为什么每轮都重新设置timeout?](#17.4 为什么每轮都重新设置timeout?)
- [17.5 为什么需要fflush(stdout)?](#17.5 为什么需要fflush(stdout)?)
- [17.6 为什么read返回0要退出?](#17.6 为什么read返回0要退出?)
- [17.7 标准输入实验说明了什么?](#17.7 标准输入实验说明了什么?)
- 十八、第二个实验:使用select实现TCP字典服务器
-
- [18.1 服务器需要实现什么功能?](#18.1 服务器需要实现什么功能?)
- [18.3 监听Socket与通信Socket有什么区别?](#18.3 监听Socket与通信Socket有什么区别?)
- 十九、先设计服务器文件与接口
-
- [19.1 为什么需要Selector类?](#19.1 为什么需要Selector类?)
- [19.2 Selector需要提供什么接口?](#19.2 Selector需要提供什么接口?)
- [19.3 为什么Selector不直接管理Socket生命周期?](#19.3 为什么Selector不直接管理Socket生命周期?)
- [19.4 TcpSelectServer需要提供什么接口?](#19.4 TcpSelectServer需要提供什么接口?)
- [19.5 Handler是什么?](#19.5 Handler是什么?)
- 二十、为什么TCP字典服务器还需要处理消息边界?
-
- [20.1 我们怎样解决?](#20.1 我们怎样解决?)
- [20.2 为什么每个客户端都需要自己的接收缓冲区?](#20.2 为什么每个客户端都需要自己的接收缓冲区?)
- 二十一、完整实现select_server.hpp
- 二十二、逐步分析Selector类
-
- [22.1 构造函数](#22.1 构造函数)
- [22.2 Add函数](#22.2 Add函数)
- [22.3 Del函数](#22.3 Del函数)
- [22.4 Wait函数](#22.4 Wait函数)
- [22.5 为什么不直接把整个TcpSocket对象复制到集合中?](#22.5 为什么不直接把整个TcpSocket对象复制到集合中?)
- 二十三、逐步分析TcpSelectServer
-
- [23.1 创建监听Socket](#23.1 创建监听Socket)
- [23.2 绑定端口](#23.2 绑定端口)
- [23.3 开始监听](#23.3 开始监听)
- [23.4 为什么监听Socket也要加入select?](#23.4 为什么监听Socket也要加入select?)
- [23.5 为什么新客户端也要加入select?](#23.5 为什么新客户端也要加入select?)
- [23.6 如果客户端断开怎么办?](#23.6 如果客户端断开怎么办?)
- 二十四、完整实现dict_server.cpp
-
- [24.1 为什么使用unordered_map?](#24.1 为什么使用unordered_map?)
- [24.2 为什么业务函数使用Handler?](#24.2 为什么业务函数使用Handler?)
- [24.3 为什么监听127.0.0.1?](#24.3 为什么监听127.0.0.1?)
- 二十五、编译运行TCP字典服务器
-
- [25.1 编译](#25.1 编译)
- [25.2 启动](#25.2 启动)
- [25.3 使用nc客户端测试](#25.3 使用nc客户端测试)
- [25.4 为什么必须按回车?](#25.4 为什么必须按回车?)
- [25.5 同时打开多个客户端](#25.5 同时打开多个客户端)
- [25.6 测试一批连续请求](#25.6 测试一批连续请求)
- 二十六、整个select事件循环究竟发生了什么?
-
- [26.1 第一轮:没有客户端数据](#26.1 第一轮:没有客户端数据)
- [26.2 第二轮:客户端B发送数据](#26.2 第二轮:客户端B发送数据)
- [26.3 第三轮:客户端A和C同时发送](#26.3 第三轮:客户端A和C同时发送)
- [26.4 第四轮:客户端C断开连接](#26.4 第四轮:客户端C断开连接)
- 二十七、这个服务器还有哪些需要注意的地方?
-
- [27.1 select只负责报告就绪](#27.1 select只负责报告就绪)
- [27.2 TCP仍然是字节流](#27.2 TCP仍然是字节流)
- [27.3 当前服务器使用阻塞式发送](#27.3 当前服务器使用阻塞式发送)
- [27.4 select返回后,操作就一定成功吗?](#27.4 select返回后,操作就一定成功吗?)
- [27.5 为什么不能简单复制Socket对象?](#27.5 为什么不能简单复制Socket对象?)
- [27.6 如果客户端数量很多怎么办?](#27.6 如果客户端数量很多怎么办?)
- 二十八、select常见面试问题
-
- [28.1 select是什么?](#28.1 select是什么?)
- [28.2 select和阻塞recv有什么区别?](#28.2 select和阻塞recv有什么区别?)
- [28.3 select是否属于异步I/O?](#28.3 select是否属于异步I/O?)
- [28.4 select的第一个参数是什么?](#28.4 select的第一个参数是什么?)
- [28.5 FD_ZERO是什么?](#28.5 FD_ZERO是什么?)
- [28.6 FD_SET是什么?](#28.6 FD_SET是什么?)
- [28.7 FD_ISSET是什么?](#28.7 FD_ISSET是什么?)
- [28.8 为什么每次调用select前要重建fd_set?](#28.8 为什么每次调用select前要重建fd_set?)
- [28.9 select返回0是什么意思?](#28.9 select返回0是什么意思?)
- [28.10 为什么监听Socket读就绪要调用accept?](#28.10 为什么监听Socket读就绪要调用accept?)
- [28.11 为什么客户端Socket读就绪后recv可能返回0?](#28.11 为什么客户端Socket读就绪后recv可能返回0?)
- [28.12 为什么select要维护maxfd?](#28.12 为什么select要维护maxfd?)
- [28.13 select最多支持多少个Socket?](#28.13 select最多支持多少个Socket?)
- [28.14 select最大的缺点是什么?](#28.14 select最大的缺点是什么?)
- 二十九、全文总结
前言:为什么有了非阻塞IO,还需要select?
在前面的Linux网络编程中,我们已经学习了五种经典I/O模型:
- 阻塞I/O
- 非阻塞I/O
- 信号驱动I/O
- I/O多路转接
- 异步I/O
其中,我们通过 fcntl() 配合:
cpp
O_NONBLOCK
将文件描述符设置为非阻塞模式。
例如:
cpp
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
这样,在没有数据可读时:
cpp
ssize_t n = read(fd, buffer, sizeof(buffer));
可以立即返回:
text
-1
并设置:
cpp
errno = EAGAIN;
而不需要让当前线程一直阻塞等待。
但是,这又引出了一个新的问题。
假设我们的TCP服务器同时连接了100个客户端:
text
客户端1 → sockfd1
客户端2 → sockfd2
客户端3 → sockfd3
......
客户端100 → sockfd100
如果使用简单的非阻塞轮询:
cpp
while (true)
{
recv(sockfd1, ...);
recv(sockfd2, ...);
recv(sockfd3, ...);
// ...
}
服务器就需要不断检查这些Socket。
即使其中99个客户端都没有发送任何数据,服务器仍然可能反复调用它们对应的 recv()。
显然,这种方式可能产生大量无意义的系统调用。
我们真正希望实现的是:
能不能让内核帮我们同时监视多个文件描述符,只有其中某些文件描述符满足读写条件时,才通知应用程序处理?
答案是可以。
Linux提供了:
cpp
select()
系统调用。
它是I/O多路转接的一种经典实现。
一、什么是I/O多路转接?
I/O多路转接,也称为:
I/O Multiplexing。
它的核心思想是:
让一个执行流通过一个统一的等待接口,同时关注多个文件描述符的就绪状态。
1.1 普通阻塞I/O的问题
假设服务器连接了三个客户端。
text
客户端A
|
v
Socket A
客户端B
|
v
Socket B
客户端C
|
v
Socket C
如果服务器首先调用:
cpp
recv(sockfdA, buffer, sizeof(buffer), 0);
当客户端A没有发送数据时,当前线程可能阻塞在这个函数中。
即使:
text
客户端B已经发送数据
也无法立即处理,因为当前线程还停留在客户端A的 recv() 上。
1.2 非阻塞轮询的改进与问题
如果把三个Socket全部设置为非阻塞:
text
读取A:没有数据 → EAGAIN
读取B:有数据 → 读取成功
读取C:没有数据 → EAGAIN
就避免了在单个Socket上一直等待。
但是,如果Socket数量达到:
text
1000个
10000个
不停轮询可能会造成较大开销。
1.3 select的解决方案
select() 的思路是:
先让内核等待多个描述符的就绪条件,等到至少一个满足条件,再让应用程序处理相应描述符。
例如:
text
应用程序
|
v
select
|
+----------+----------+
| | |
v v v
sockfdA sockfdB sockfdC
| | |
未就绪 已就绪 未就绪
|
v
select返回
|
v
处理sockfdB
因此:
text
应用程序不需要持续反复尝试读取所有Socket
而可以:
text
等待内核报告哪些Socket已经满足条件
1.4 select是不是异步I/O?
不是。
在经典五种I/O模型中,select属于:
同步I/O多路转接。
因为:
text
select负责等待就绪事件
而:
text
read / recv负责真正读取数据
整个过程:
text
select等待
|
v
发现Socket就绪
|
v
应用程序调用recv
|
v
数据拷贝到用户空间
|
v
应用程序处理数据
select并不会自动替应用程序完成整个读取操作。
1.5 select一定不会阻塞吗?
恰恰相反。
select() 本身通常就是一个可以阻塞等待的系统调用。
但它和直接阻塞在某一个Socket上的 recv() 不同:
text
普通阻塞recv:
等待一个Socket上的读取条件
text
select:
同时等待多个描述符的就绪条件
因此:
select的价值不是让线程永远不阻塞,而是提高一个线程等待多个I/O对象时的效率。

二、select函数原型
Linux提供了:
cpp
#include <sys/select.h>
其中定义了:
cpp
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout
);
这个函数有五个参数。
初次接触时,可能觉得非常复杂。
但实际上可以分为三组:
text
nfds:
告诉内核要检查的文件描述符编号范围
text
readfds / writefds / exceptfds:
告诉内核需要关注哪些就绪条件
text
timeout:
告诉内核最多等待多长时间
接下来逐个讲解。
三、第一个参数nfds是什么意思?
函数原型:
cpp
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout
);
其中:
cpp
nfds
表示:
需要检查的最大文件描述符编号加1。
3.1 为什么不是文件描述符数量?
假设我们需要监视三个文件描述符:
text
fd = 3
fd = 5
fd = 8
虽然只有三个描述符,但是最大编号是:
text
8
因此:
cpp
nfds = 8 + 1;
即:
cpp
nfds = 9;
调用:
cpp
select(9, ...);
3.2 为什么要加1?
因为文件描述符从0开始编号。
例如:
text
0 1 2 3 4 5 6 7 8
如果要检查的最大描述符为8,那么需要考虑:
text
0~8
这些可能的编号位置。
所以:
n f d s = m a x f d + 1 nfds=maxfd+1 nfds=maxfd+1
3.3 另一个例子
假设监视:
text
fd = 4
fd = 7
fd = 12
最大值:
text
12
因此:
cpp
select(13, ...);
注意:
nfds不是需要监视的描述符个数,而是最大描述符编号加1。
这是使用select时最容易出错的地方之一。
四、readfds、writefds、exceptfds分别是什么?
select可以监视三种类型的事件。
cpp
fd_set *readfds;
表示:
需要检查可读状态的描述符集合。
cpp
fd_set *writefds;
表示:
需要检查可写状态的描述符集合。
cpp
fd_set *exceptfds;
表示:
需要检查异常条件的描述符集合。
4.1 readfds:读就绪集合
假设我们希望检查:
text
sockfd1是否可读?
sockfd2是否可读?
sockfd3是否可读?
那么就需要把这三个描述符加入:
cpp
readfds
集合。
调用select以后,可以检查哪些描述符满足可读条件。
4.2 writefds:写就绪集合
例如:
text
某个Socket当前是否能够进行写操作?
就可以使用:
cpp
writefds
但这里的"可写"不表示无论发送多少数据都绝对不会等待。
它通常表示:
当前满足一定的写入就绪条件。
如果尝试发送大量数据,仍然可能出现部分写入或其他情况。
4.3 exceptfds:异常条件集合
用于监视特定的异常条件。
例如TCP带外数据相关的异常就绪情况。
需要特别注意:
exceptfds不是普通Socket错误事件的总集合。
不能把:
cpp
exceptfds
简单理解为:
text
所有网络异常都在这里报告
当前阶段只需要知道它的存在即可。
4.4 如果只需要监视读事件怎么办?
可以将其他集合设置为:
cpp
NULL
例如:
cpp
select(
maxfd + 1,
&readfds,
NULL,
NULL,
NULL
);
表示:
text
只检查readfds中的可读事件
不检查:
text
writefds
exceptfds
本篇的字典服务器首先就采用这种方式。
五、第五个参数timeout是什么意思?
select() 的最后一个参数:
cpp
struct timeval *timeout;
用于控制等待时间。
5.1 timeval结构
通常可以理解为:
cpp
struct timeval
{
time_t tv_sec;
suseconds_t tv_usec;
};
其中:
cpp
tv_sec
表示秒。
cpp
tv_usec
表示微秒。
需要注意:
1 秒 = 1000000 微秒 1\text{秒}=1000000\text{微秒} 1秒=1000000微秒
例如:
cpp
struct timeval timeout;
timeout.tv_sec = 2;
timeout.tv_usec = 500000;
表示:
text
2.5秒
5.2 第一种情况:timeout为NULL
例如:
cpp
select(
maxfd + 1,
&readfds,
NULL,
NULL,
NULL
);
这里最后一个参数为:
cpp
NULL
表示:
不设置超时期限。
如果没有描述符满足就绪条件,select可以一直等待。
直到出现:
text
某些描述符就绪
信号中断
系统调用错误
等情况。
所以并不是严格意义上的"永远无法返回"。
5.3 第二种情况:timeout为0
注意,这里的timeout为0,通常是指:
cpp
struct timeval timeout = {0, 0};
然后:
cpp
select(
maxfd + 1,
&readfds,
NULL,
NULL,
&timeout
);
这样表示:
只检查当前状态,然后立即返回,不等待外部事件。
例如:
text
当前有就绪描述符:
返回就绪信息
text
当前没有就绪描述符:
立即返回0
5.4 第三种情况:指定超时时间
例如:
cpp
struct timeval timeout = {5, 0};
表示:
text
最多等待5秒
调用:
cpp
int ret = select(
maxfd + 1,
&readfds,
NULL,
NULL,
&timeout
);
如果5秒内没有对应就绪事件:
cpp
ret == 0
如果在5秒内有描述符就绪:
cpp
ret > 0
则可以提前返回。
5.5 三种模式总结
| timeout | 含义 |
|---|---|
NULL |
不设置超时期限,可持续阻塞等待 |
{0, 0} |
立即检测,不等待 |
{5, 0} |
最多等待约5秒 |
需要注意,实际等待时长还可能受到系统调度和信号等因素影响。
此外,在Linux上,select可能修改传入的 timeval。
因此如果需要重复使用固定的超时时间:
最好在每次调用select之前重新初始化timeval。

六、什么是fd_set?
现在正式学习select的核心数据结构:
cpp
fd_set
前面一直在说:
text
可读集合
可写集合
异常集合
那么:
select究竟怎样表示一组文件描述符?
答案是:
使用fd_set。
6.1 fd_set可以理解为位图
在常见实现中:
cpp
fd_set
可以理解为使用位图表示文件描述符集合。
所谓位图,就是:
使用一个二进制位表示某个编号的状态。
例如:
text
00000000
这是一个8位的示意位图。
我们暂时假设:
text
bit0代表fd0
bit1代表fd1
bit2代表fd2
......
bit7代表fd7
如果:
text
bit3 = 1
表示:
text
fd3在集合中
如果:
text
bit3 = 0
表示:
text
fd3不在集合中
6.2 为什么使用位图?
因为文件描述符本质上是整数编号。
例如:
text
fd0
fd1
fd2
fd3
可以直接对应位图中的不同位置。
这样就能够用紧凑的数据结构记录:
text
哪些文件描述符需要监视
6.3 fd_set是不是一个普通的int数组?
从实现角度看,常见Linux C库中的 fd_set 可以由整数类型的数组构成。
例如典型的历史实现思路:
c
typedef long int __fd_mask;
typedef struct
{
__fd_mask fds_bits[...];
} fd_set;
这段代码仅用于解释位图思想。
不同系统与C库版本的内部结构体名称、成员名和实现细节可能有所不同。
实际程序不应该直接操作这些内部成员,而应该使用标准宏:
cpp
FD_ZERO
FD_SET
FD_CLR
FD_ISSET
这样代码才更容易理解,也更符合规范接口的使用方式。
七、操作fd_set的四个重要宏
Linux为我们提供了一组操作 fd_set 的宏。
7.1 FD_ZERO:清空集合
函数形式:
cpp
FD_ZERO(fd_set *set);
作用:
将集合中的所有位清零。
例如:
cpp
fd_set readfds;
FD_ZERO(&readfds);
此时:
text
readfds中没有任何描述符被设置
用8位示意:
text
00000000
7.2 FD_SET:添加描述符
形式:
cpp
FD_SET(int fd, fd_set *set);
作用:
将指定文件描述符加入集合。
例如:
cpp
FD_SET(3, &readfds);
表示:
text
监视fd3
7.3 FD_CLR:删除描述符
形式:
cpp
FD_CLR(int fd, fd_set *set);
作用:
将指定描述符从集合中移除。
例如:
cpp
FD_CLR(3, &readfds);
表示:
text
不再监视fd3
7.4 FD_ISSET:检查描述符是否在集合中
形式:
cpp
FD_ISSET(int fd, fd_set *set);
例如:
cpp
if (FD_ISSET(3, &readfds))
{
// fd3对应的位被设置
}
在select返回以后,可以通过它检查:
text
fd3是否被报告为就绪
7.5 四个宏总结
| 宏 | 作用 |
|---|---|
FD_ZERO |
清空整个描述符集合 |
FD_SET |
向集合加入一个fd |
FD_CLR |
从集合删除一个fd |
FD_ISSET |
判断指定fd对应的位是否被设置 |
这四个宏是select编程中必须熟练掌握的基础接口。
八、通过位图理解select的执行过程
这里我们用一个简化模型分析。
假设:
text
fd_set只有8位
能够表示:
text
fd0~fd7
实际上,真实Linux中的 fd_set 并不是只有一个字节。
这里仅仅是为了方便手工理解。
8.1 第一步:创建并清空集合
cpp
fd_set readfds;
FD_ZERO(&readfds);
此时:
text
bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
0 0 0 0 0 0 0 0
可以写成:
text
00000000
8.2 第二步:加入fd5
cpp
FD_SET(5, &readfds);
将bit5设置为1。
结果:
text
00100000
注意:
我们从右向左编号,最右侧是bit0。
因此fd5对应:
text
00100000
而不是bit4。
8.3 第三步:加入fd2和fd1
cpp
FD_SET(2, &readfds);
FD_SET(1, &readfds);
此时:
text
00100110
对应:
text
fd5 = 1
fd2 = 1
fd1 = 1
也就是当前监视:
text
fd1
fd2
fd5
8.4 第四步:调用select
最大描述符:
text
5
因此:
cpp
select(
6,
&readfds,
NULL,
NULL,
NULL
);
这里:
text
nfds = 5 + 1 = 6
select开始等待这三个描述符的可读条件。
8.5 第五步:假设fd1和fd2就绪
假设:
text
fd1:可读
fd2:可读
fd5:不可读
那么select返回后:
text
readfds = 00000110
为什么?
因为:
text
fd1对应的位保留
fd2对应的位保留
fd5没有就绪,对应的位被清除
这时:
cpp
FD_ISSET(1, &readfds)
结果为真。
cpp
FD_ISSET(2, &readfds)
结果为真。
而:
cpp
FD_ISSET(5, &readfds)
结果为假。
8.6 最终过程
text
初始化:
00000000
加入fd5:
00100000
加入fd2、fd1:
00100110
调用select:
监视fd1、fd2、fd5
fd1、fd2就绪
select返回:
00000110
这就是理解select执行过程的重要基础。

九、select会修改fd_set:为什么这是一个大坑?
前面的例子告诉我们:
调用select之前:
text
00100110
表示:
text
监视fd1、fd2、fd5
但是调用select之后:
text
00000110
因为:
text
fd5没有就绪
对应位置被清除了。
问题来了:
如果下一次还想继续监视fd5,该怎么办?
9.1 错误写法
例如:
cpp
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(1, &readfds);
FD_SET(2, &readfds);
FD_SET(5, &readfds);
while (true)
{
select(6, &readfds, NULL, NULL, NULL);
// 处理就绪事件
}
为什么可能出错?
因为:
text
select会修改readfds
第一次调用之后,如果fd5没有就绪,它对应的位被清除。
下一轮又直接使用修改后的:
cpp
readfds
那么fd5就不再被监视了。
9.2 正确思路一:每轮重新设置
例如:
cpp
while (true)
{
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(1, &readfds);
FD_SET(2, &readfds);
FD_SET(5, &readfds);
int ret = select(
6,
&readfds,
NULL,
NULL,
NULL
);
// 检查返回结果
}
这样每一轮都重新构造:
text
需要监视的描述符集合
9.3 正确思路二:保存主集合
更适合服务器的方式是维护两套集合。
text
主集合master:
保存所有需要长期监视的描述符
text
临时集合temp:
交给select使用
每次调用之前:
cpp
fd_set temp = master;
然后:
cpp
select(maxfd + 1, &temp, NULL, NULL, NULL);
即使select修改了:
cpp
temp
也不会改变:
cpp
master
9.4 为什么这个设计很重要?
因为服务器运行过程中不断有客户端:
text
建立连接
断开连接
例如:
text
客户端A连接 → 加入主集合
客户端B连接 → 加入主集合
客户端A断开 → 从主集合删除
然后每次:
text
从主集合复制出临时集合
让select处理。
这种设计可以避免反复丢失需要监视的文件描述符。

十、select的返回值
select返回:
cpp
int ret;
通常有三种情况。
10.1 返回正数
例如:
cpp
ret = 2;
表示有相应的就绪条件被报告。
如果我们只使用:
cpp
readfds
那么通常可以理解为:
text
有2个描述符被报告为可读
如果同时使用读集合和写集合,同一个fd可能在不同集合中分别计入就绪条件。
因此不能在所有情况下把返回值简单理解为"不同文件描述符的数量"。
10.2 返回0
例如:
cpp
ret == 0
表示:
在指定等待时间内,没有相应就绪条件满足,select超时返回。
需要注意:
如果:
cpp
timeout == NULL
表示没有指定超时期限。
这时正常情况下不会因为"等待时间到了"而返回0。
10.3 返回-1
例如:
cpp
ret == -1
表示调用失败。
可以通过:
cpp
errno
判断错误原因。
常见错误包括:
| 错误码 | 含义 |
|---|---|
EBADF |
存在无效的被检查描述符 |
EINTR |
调用被信号中断 |
EINVAL |
参数不合法,例如nfds为负 |
ENOMEM |
内核内存资源不足 |
10.4 为什么需要处理EINTR?
例如:
cpp
int ret = select(...);
执行期间收到信号。
select可能返回:
text
-1
并设置:
cpp
errno == EINTR
这种情况可以根据程序逻辑重新等待。
例如:
cpp
if (ret < 0)
{
if (errno == EINTR)
{
continue;
}
perror("select");
break;
}
不过重新调用时,需要重新准备输入描述符集合和适当的timeout。
十一、Socket什么时候算读就绪?
现在进入select真正重要的部分:
什么叫文件描述符就绪?
对于Socket,我们主要讨论:
text
读就绪
写就绪
11.1 第一种:接收缓冲区中有可读取数据
假设客户端发送:
text
hello
服务器内核收到数据。
当接收缓冲区满足相应可读低水位等条件时:
text
Socket可读
此时select可以报告:
text
读就绪
应用程序随后调用:
cpp
recv(sockfd, buffer, sizeof(buffer), 0);
即可尝试读取数据。
11.2 第二种:TCP对端关闭连接
假设客户端已经正常关闭了发送方向。
那么服务器可能观察到:
text
Socket读就绪
但随后执行:
cpp
recv(sockfd, buffer, sizeof(buffer), 0);
返回:
text
0
为什么?
因为:
text
TCP对端已经正常结束发送
此时读取到了EOF。
所以一定要记住:
读就绪不代表recv一定返回正数。
它也可能意味着:
text
读取将返回0
11.3 第三种:监听Socket存在待接受连接
假设服务器执行:
cpp
listen(listenfd, 16);
之后有客户端发起连接。
当有连接进入等待接受的队列并满足条件时:
text
listenfd可读
于是select可以报告:
text
监听Socket读就绪
但这里不能调用:
cpp
recv(listenfd, ...);
因为监听Socket不是用于接收某个已连接客户端业务数据的Socket。
应该调用:
cpp
accept(listenfd, ...);
获得新的连接描述符。
11.4 第四种:Socket存在待处理错误
某些Socket错误也可能使相应描述符被报告为可读。
之后进行读取或检查错误状态时,才能知道具体情况。
所以:
读就绪只表示满足了相应读取相关条件,不保证一定获得正常业务数据。
十二、Socket什么时候算写就绪?
写就绪同样非常重要。
12.1 发送缓冲区具有可用空间
TCP Socket内部具有发送缓冲区。
例如:
text
Socket发送缓冲区
+-------------------------------+
| 已使用数据 | 剩余可用空间 |
+-------------------------------+
当可用空间满足相应写入低水位等条件时:
text
Socket可以报告写就绪
应用程序可以尝试执行:
cpp
send(sockfd, buffer, size, 0);
12.2 写就绪不意味着可以一次写入任意长度
假设:
text
发送缓冲区当前还有1024字节可用空间
但是应用程序试图发送:
text
100MB数据
那么不能仅凭:
text
select报告写就绪
就断言整个100MB都能够一次写入成功。
TCP发送操作仍然需要考虑:
text
部分写入
缓冲区空间变化
系统调用返回值
等问题。
12.3 非阻塞connect完成以后
如果使用非阻塞Socket执行:
cpp
connect()
连接建立过程可能尚未完成。
当Socket被报告为可写时,可能意味着:
text
连接建立成功
也可能意味着:
text
连接建立失败
此时不能仅凭写就绪判断连接成功。
通常还需要:
cpp
getsockopt(
sockfd,
SOL_SOCKET,
SO_ERROR,
...
);
检查连接结果。
12.4 关闭写方向与错误情况
如果连接的写方向被关闭,或者对端已经不再接收数据:
cpp
send()
可能失败。
在某些情况下,还可能涉及:
cpp
SIGPIPE
信号。
因此:
select报告可写,不意味着之后的send一定成功。
实际发送仍然必须检查返回值。
十三、异常就绪是什么?
select还支持:
cpp
exceptfds
用于检测特定异常条件。
例如TCP带外数据相关的情况。
TCP报头中存在:
text
URG
Urgent Pointer
等与紧急数据有关的字段。
在某些Socket场景中,可以通过异常条件集合监视相关事件。
当前阶段只需要建立以下认识:
text
readfds:
监视可读条件
text
writefds:
监视可写条件
text
exceptfds:
监视特定异常条件,例如带外数据相关状态
普通TCP服务端主要先掌握:
cpp
readfds
的使用。
等到需要发送缓冲区管理时,再进一步使用:
cpp
writefds
十四、select的特点
现在我们已经理解select的基本工作方式。
接下来分析它的特点。
14.1 可以同时监视多个文件描述符
这是select最大的优势。
例如:
text
监听Socket
客户端A的Socket
客户端B的Socket
客户端C的Socket
可以同时放入一个读集合中。
cpp
FD_SET(listenfd, &readfds);
FD_SET(sockfdA, &readfds);
FD_SET(sockfdB, &readfds);
FD_SET(sockfdC, &readfds);
然后:
cpp
select(maxfd + 1, &readfds, NULL, NULL, NULL);
统一等待。
14.2 select对描述符编号有上限
在常见Linux C库中:
cpp
FD_SETSIZE
通常定义为:
text
1024
因此传统select接口的 fd_set 有固定容量限制。
必须注意:
这不是说select最多能够处理1024个客户端。
因为:
text
标准输入fd0
标准输出fd1
标准错误fd2
监听fd
其他已打开文件描述符
都可能占用编号。
更重要的是:
select限制的是fd_set能够表示的描述符编号范围,而不是单纯的连接数量。
例如:
text
fd = 1500
即使当前只想监视这一个描述符,在常见 FD_SETSIZE=1024 的用户态接口中,也不能安全地直接执行:
cpp
FD_SET(1500, &readfds);
这可能造成越界与未定义行为。
不同平台和实现可能存在差异,实际程序应该遵守目标平台的接口限制。
14.3 为什么还需要保存原始描述符集合?
因为:
text
select会修改传入的fd_set
如果每次都需要重新监视原有描述符,就必须:
text
重新构造集合
或者:
text
保存主集合,再复制临时集合
14.4 为什么还要保存maxfd?
因为select需要:
cpp
maxfd + 1
作为第一个参数。
假设:
text
监听fd = 3
客户端A fd = 4
客户端B fd = 5
此时:
text
maxfd = 5
所以:
cpp
select(6, ...);
如果后来:
text
客户端B断开
并且:
text
fd5被删除
那么最大描述符就可能变成:
text
4
因此需要重新维护最大值。
十五、select的缺点
select能够解决简单轮询的问题,但并不是完美的方案。
15.1 缺点一:每次都需要准备描述符集合
因为select会修改:
cpp
fd_set
每次调用之前,都需要准备新的输入集合。
例如:
cpp
fd_set temp = master;
这会增加代码管理成本。
15.2 缺点二:存在用户态与内核态的数据复制
select需要将用户程序提供的描述符集合交给内核处理。
每次调用都涉及相关数据传递。
当描述符规模比较大时,会产生额外开销。
15.3 缺点三:内核需要检查描述符集合
内核需要检查传入的相关描述符和就绪条件。
当需要监视的描述符数量较多时,检查成本可能增加。
15.4 缺点四:应用程序需要找出就绪描述符
select返回后,程序还需要执行:
cpp
FD_ISSET(fd, &readfds);
检查哪些描述符被报告为就绪。
例如:
cpp
for (int fd = 0; fd <= maxfd; ++fd)
{
if (FD_ISSET(fd, &readfds))
{
// 处理fd
}
}
这意味着:
应用程序也可能需要扫描较大的描述符编号范围。
15.5 缺点五:fd_set存在容量限制
在常见Linux用户态接口中,FD_SETSIZE 限制使select难以直接管理非常大的描述符编号。
因此,当并发连接数量较高时,select存在明显局限性。
15.6 select、poll、epoll的学习关系
text
select
|
v
poll
|
v
epoll
这是常见的学习顺序。
后面学习poll和epoll时,会进一步讨论:
text
接口组织方式
监视对象管理
就绪事件获取
大规模连接场景的性能特点
本篇先把select理解透彻。
十六、第一个实验:使用select监听标准输入
现在我们已经掌握了select的基本接口。
接下来编写一个最简单的程序。
16.1 整个文件要实现什么功能?
我们希望程序:
text
启动
|
v
等待标准输入
|
v
用户输入一行文字
|
v
select报告标准输入可读
|
v
read读取内容
|
v
打印输入
|
v
继续下一轮等待
另外,我们还希望增加一个:
text
5秒超时
如果5秒内没有任何输入,就打印:
text
select timeout
然后继续等待。
16.2 需要设计哪些接口?
这个实验非常简单。
只需要:
cpp
int main();
使用:
cpp
FD_ZERO
清空集合。
cpp
FD_SET
加入标准输入。
cpp
select
等待就绪。
cpp
FD_ISSET
判断标准输入是否可读。
cpp
read
读取数据。
16.3 标准输入的文件描述符
Linux中:
cpp
STDIN_FILENO
通常为:
text
0
因此我们只需要监视:
text
fd0
最大描述符:
text
0
所以:
cpp
nfds = 0 + 1 = 1
调用:
cpp
select(1, ...);
十七、标准输入实验完整代码
文件名:
text
select_stdin.c
完整实现:
c
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/select.h>
int main(void)
{
while (1)
{
// 1. 定义需要监视的读集合
fd_set readfds;
// 2. 每轮重新清空集合
FD_ZERO(&readfds);
// 3. 加入标准输入
FD_SET(STDIN_FILENO, &readfds);
// 4. 设置5秒超时
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
printf("> ");
fflush(stdout);
// 5. 等待标准输入可读
int ret = select(
STDIN_FILENO + 1,
&readfds,
NULL,
NULL,
&timeout
);
// 6. 处理select错误
if (ret < 0)
{
if (errno == EINTR)
{
continue;
}
perror("select");
break;
}
// 7. 超时
if (ret == 0)
{
printf("\nselect timeout\n");
continue;
}
// 8. 检查标准输入是否可读
if (FD_ISSET(STDIN_FILENO, &readfds))
{
char buffer[1024];
ssize_t n = read(
STDIN_FILENO,
buffer,
sizeof(buffer)
);
if (n > 0)
{
printf("\ninput: ");
fwrite(buffer, 1, (size_t)n, stdout);
fflush(stdout);
}
else if (n == 0)
{
printf("\nEOF, exit.\n");
break;
}
else if (errno != EINTR)
{
perror("read");
break;
}
}
}
return 0;
}
17.1 编译
bash
gcc -Wall -Wextra select_stdin.c -o select_stdin
17.2 运行
bash
./select_stdin
可能显示:
text
>
如果5秒内没有输入:
text
select timeout
>
再次等待5秒:
text
select timeout
>
如果输入:
text
hello
并按回车:
text
input: hello
>
17.3 为什么每次循环都调用FD_ZERO?
因为:
cpp
select()
会修改传入的:
cpp
readfds
所以每轮必须重新构造:
text
需要监视的集合
17.4 为什么每轮都重新设置timeout?
因为在Linux中,select可能修改:
cpp
timeout
所以每次都重新初始化:
cpp
timeout.tv_sec = 5;
timeout.tv_usec = 0;
能够保证每一轮重新等待约5秒。
17.5 为什么需要fflush(stdout)?
因为:
cpp
printf("> ");
没有换行符。
在某些输出缓冲条件下,提示符可能暂时不会立即显示。
因此:
cpp
fflush(stdout);
用于刷新输出缓冲区。
17.6 为什么read返回0要退出?
对于当前终端输入场景:
cpp
read() == 0
通常表示EOF。
如果不退出:
text
标准输入持续处于EOF状态
程序可能再次立即从select返回,然后反复读取到0,形成无意义循环。
17.7 标准输入实验说明了什么?
这里我们实际上完成了:
text
用户输入
|
v
标准输入满足可读条件
|
v
select返回
|
v
FD_ISSET判断
|
v
read读取数据
而不是:
text
程序一直阻塞在read上
虽然select本身仍然可能阻塞,但它为后面监视多个Socket奠定了基础。

十八、第二个实验:使用select实现TCP字典服务器
前面我们只监视:
text
标准输入fd0
这还没有完全体现多路转接的价值。
接下来实现一个:
同时处理多个TCP客户端的字典服务器。
18.1 服务器需要实现什么功能?
假设客户端输入:
text
hello
服务器返回:
text
你好
客户端输入:
text
apple
服务器返回:
text
苹果
客户端输入:
text
linux
服务器返回:
text
Linux操作系统
如果输入未知单词:
text
unknown
服务器返回:
text
not found
我们的重点不是字典查询本身。
而是:
让一个服务器线程通过select管理多个TCP连接。
18.3 监听Socket与通信Socket有什么区别?
对于TCP服务器:
cpp
listenfd
负责:
text
监听新的连接请求
当它读就绪时,通常执行:
cpp
accept()
获得:
cpp
clientfd
而:
cpp
clientfd
负责:
text
接收具体客户端发送的数据
当它读就绪时,通常执行:
cpp
recv()
所以服务器必须区分:
text
listenfd读就绪 → accept
和:
text
clientfd读就绪 → recv
这也是select服务器事件循环的核心。
十九、先设计服务器文件与接口
为了让整个程序结构清晰,我们使用两个文件。
text
select-server/
│
├── select_server.hpp
│
└── dict_server.cpp
其中:
text
select_server.hpp
负责:
text
Selector描述符管理
TcpSelectServer网络服务
连接建立
连接关闭
数据读取与响应发送
而:
text
dict_server.cpp
负责:
text
定义字典内容
实现业务处理函数
创建服务器对象
启动服务
这样的设计让:
text
网络处理逻辑
和:
text
业务逻辑
分开。
19.1 为什么需要Selector类?
因为如果每次在TCP服务器中手动维护:
cpp
FD_SET
cpp
FD_CLR
cpp
FD_ISSET
以及:
cpp
maxfd
会使服务器逻辑比较混乱。
因此,我们设计:
cpp
class Selector;
专门管理select相关操作。
19.2 Selector需要提供什么接口?
cpp
class Selector
{
public:
bool Add(int fd);
void Del(int fd);
int Wait(std::vector<int>* ready);
};
其中:
cpp
Add()
把一个描述符加入监视集合。
cpp
Del()
从监视集合中删除一个描述符。
cpp
Wait()
等待就绪事件,并把就绪的描述符交给调用者。
19.3 为什么Selector不直接管理Socket生命周期?
因为:
text
监视一个fd
和:
text
拥有并关闭这个fd
是两种不同的职责。
在这个实现中:
text
Selector只保存文件描述符编号
由:
text
TcpSelectServer
负责实际关闭连接。
这样可以避免多个对象同时认为自己拥有同一个Socket,导致重复关闭等问题。
19.4 TcpSelectServer需要提供什么接口?
cpp
class TcpSelectServer
{
public:
TcpSelectServer(
const std::string& ip,
uint16_t port
);
bool Start(const Handler& handler);
};
其中:
cpp
Start()
负责完整服务流程。
需要完成:
text
创建Socket
|
v
绑定IP和端口
|
v
进入监听状态
|
v
把监听fd加入Selector
|
v
进入select事件循环
|
v
处理新连接
|
v
处理客户端请求
19.5 Handler是什么?
业务处理函数定义为:
cpp
using Handler = std::function<
void(const std::string&, std::string*)
>;
参数:
cpp
const std::string& req
表示客户端请求。
cpp
std::string* resp
用于保存服务器响应。
例如:
cpp
handler("hello", &resp);
最后:
text
resp = "你好"
通过这种方式,TCP服务器不需要关心:
text
具体查询什么字典
只负责:
text
接收请求
调用Handler
发送响应
二十、为什么TCP字典服务器还需要处理消息边界?
这里先处理一个重要问题。
我们已经知道:
TCP是字节流协议。
因此客户端执行:
cpp
send(sockfd, "hello", 5, 0);
不代表服务器执行一次:
cpp
recv()
就一定恰好得到:
text
hello
例如服务器可能分别收到:
text
hel
和:
text
lo
也可能一次收到多条请求。
例如:
text
hello\nworld\n
如果直接把每次recv得到的内容当作完整请求,就可能出现解析错误。
20.1 我们怎样解决?
这里使用简单的:
换行符分隔协议。
规定:
text
客户端每发送一个单词,以\n结束
例如:
text
hello\n
服务器响应:
text
你好\n
这样服务器可以将收到的TCP字节流持续放入缓冲区。
只有找到:
cpp
'\n'
时,才认为一条请求完整。
20.2 为什么每个客户端都需要自己的接收缓冲区?
因为不同客户端可能分别发送:
text
客户端A:
hel
以及:
text
客户端B:
app
服务器必须分别保存它们的未完成请求。
不能把所有客户端的数据混在一起。
因此在服务器内部维护:
cpp
std::unordered_map<int, std::string> inputs_;
其中:
text
key:客户端文件描述符
value:该客户端尚未组成完整请求的数据
例如:
text
fd4 → "hel"
fd5 → "app"
后续如果fd4继续收到:
text
lo\n
就组成:
text
hello\n
然后可以查询字典。
这一步虽然属于TCP应用层消息组织,但对于写出能够正确处理连续请求的服务器很重要。
二十一、完整实现select_server.hpp
下面给出完整代码。
这个版本使用系统Socket接口实现底层网络操作,不依赖额外的TcpSocket封装文件,可以直接在Ubuntu上编译。
需要说明:
本例的重点是select的读事件管理 。为了保持代码范围适合初学者,响应发送仍采用阻塞式 send() 循环。
因此它是完整的教学示例,而不是已经解决慢客户端、无限连接等全部问题的生产级高并发服务器。
文件:
text
select_server.hpp
完整实现:
cpp
#pragma once
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
// 业务函数:输入请求,填写响应
using Handler = std::function<
void(const std::string&, std::string*)
>;
// ============================
// Selector:管理select读集合
// ============================
class Selector
{
public:
Selector() : max_fd_(-1)
{
// 必须初始化
FD_ZERO(&master_);
}
// 添加一个需要监视的文件描述符
bool Add(int fd)
{
// 避免超过fd_set支持范围
if (fd < 0 || fd >= FD_SETSIZE)
{
return false;
}
FD_SET(fd, &master_);
if (fd > max_fd_)
{
max_fd_ = fd;
}
return true;
}
// 删除一个文件描述符
void Del(int fd)
{
if (fd < 0 || fd >= FD_SETSIZE)
{
return;
}
FD_CLR(fd, &master_);
// 如果删除的是最大fd,需要重新计算
if (fd == max_fd_)
{
while (
max_fd_ >= 0 &&
!FD_ISSET(max_fd_, &master_)
)
{
--max_fd_;
}
}
}
// 等待读就绪事件
// 返回-1:错误
// 返回正数:报告的就绪描述符数量
int Wait(std::vector<int>* ready)
{
ready->clear();
if (max_fd_ < 0)
{
return 0;
}
// select会修改传入的fd_set
// 因此必须使用临时集合
fd_set temp = master_;
int ret = select(
max_fd_ + 1,
&temp,
nullptr,
nullptr,
nullptr
);
if (ret <= 0)
{
return ret;
}
// 根据返回后的临时集合查找就绪fd
for (int fd = 0; fd <= max_fd_; ++fd)
{
if (FD_ISSET(fd, &temp))
{
ready->push_back(fd);
}
}
return ret;
}
private:
// 保存长期需要监视的描述符集合
fd_set master_;
// 当前最大文件描述符
int max_fd_;
};
// =================================
// TcpSelectServer:TCP字典服务器
// =================================
class TcpSelectServer
{
public:
TcpSelectServer(
const std::string& ip,
uint16_t port
)
: ip_(ip),
port_(port)
{
}
// 启动服务器
bool Start(const Handler& handler)
{
// 1. 创建监听Socket
int listen_fd = socket(
AF_INET,
SOCK_STREAM,
0
);
if (listen_fd < 0)
{
perror("socket");
return false;
}
// 2. 设置地址复用
int opt = 1;
if (setsockopt(
listen_fd,
SOL_SOCKET,
SO_REUSEADDR,
&opt,
sizeof(opt)) < 0)
{
perror("setsockopt");
close(listen_fd);
return false;
}
// 3. 配置服务器IP与端口
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port_);
if (inet_pton(
AF_INET,
ip_.c_str(),
&addr.sin_addr) != 1)
{
fprintf(
stderr,
"invalid IP: %s\n",
ip_.c_str()
);
close(listen_fd);
return false;
}
// 4. 绑定
if (bind(
listen_fd,
reinterpret_cast<sockaddr*>(&addr),
sizeof(addr)) < 0)
{
perror("bind");
close(listen_fd);
return false;
}
// 5. 监听
if (listen(listen_fd, 16) < 0)
{
perror("listen");
close(listen_fd);
return false;
}
// 6. 创建Selector
Selector selector;
// 监听Socket也需要由select监视
if (!selector.Add(listen_fd))
{
fprintf(
stderr,
"listen fd exceeds FD_SETSIZE\n"
);
close(listen_fd);
return false;
}
printf(
"listening on %s:%u\n",
ip_.c_str(),
static_cast<unsigned>(port_)
);
// 7. 主事件循环
while (true)
{
std::vector<int> ready;
int ret = selector.Wait(&ready);
if (ret < 0)
{
if (errno == EINTR)
{
continue;
}
perror("select");
break;
}
// 8. 遍历所有读就绪的描述符
for (int fd : ready)
{
// 监听Socket就绪:
// 有新的客户端连接
if (fd == listen_fd)
{
int client_fd = accept(
listen_fd,
nullptr,
nullptr
);
if (client_fd < 0)
{
if (errno != EINTR)
{
perror("accept");
}
continue;
}
// 9. 新连接加入select
if (!selector.Add(client_fd))
{
fprintf(
stderr,
"fd=%d exceeds select limit\n",
client_fd
);
close(client_fd);
continue;
}
// 每个客户端使用独立输入缓冲区
inputs_[client_fd] = "";
printf(
"client connected, fd=%d\n",
client_fd
);
}
// 普通客户端Socket就绪
else
{
HandleClient(
fd,
selector,
handler
);
}
}
}
// 错误退出时关闭所有连接
for (auto& item : inputs_)
{
close(item.first);
}
inputs_.clear();
close(listen_fd);
return false;
}
private:
// 发送全部响应
// 本教学版本使用阻塞式发送
bool SendAll(
int fd,
const std::string& data
)
{
size_t sent = 0;
while (sent < data.size())
{
ssize_t n = send(
fd,
data.data() + sent,
data.size() - sent,
MSG_NOSIGNAL
);
if (n > 0)
{
sent += static_cast<size_t>(n);
}
else if (n < 0 && errno == EINTR)
{
continue;
}
else
{
return false;
}
}
return true;
}
// 移除并关闭客户端
void CloseClient(
int fd,
Selector& selector
)
{
// 先从select中移除
selector.Del(fd);
// 删除该客户端的输入缓冲区
inputs_.erase(fd);
// 最后关闭Socket
close(fd);
printf(
"client disconnected, fd=%d\n",
fd
);
}
// 处理客户端的数据
void HandleClient(
int fd,
Selector& selector,
const Handler& handler
)
{
char buffer[2048];
ssize_t n = recv(
fd,
buffer,
sizeof(buffer),
0
);
// 对端正常关闭
if (n == 0)
{
CloseClient(fd, selector);
return;
}
// 读取出错
if (n < 0)
{
if (
errno == EINTR ||
errno == EAGAIN ||
errno == EWOULDBLOCK
)
{
return;
}
perror("recv");
CloseClient(fd, selector);
return;
}
// 取得该客户端独立的输入缓冲区
std::string& pending = inputs_[fd];
// TCP是字节流,需要累积数据
pending.append(
buffer,
static_cast<size_t>(n)
);
while (true)
{
// 查找一条完整的请求
size_t pos = pending.find('\n');
if (pos == std::string::npos)
{
break;
}
// 提取一条请求
std::string req = pending.substr(0, pos);
// 从缓冲区删除已经处理的部分
pending.erase(0, pos + 1);
// 兼容CRLF换行
if (!req.empty() && req.back() == '\r')
{
req.pop_back();
}
// 限制单条请求长度
if (req.size() > 8192)
{
CloseClient(fd, selector);
return;
}
// 调用业务处理函数
std::string resp;
handler(req, &resp);
// 采用换行符分隔响应
resp.push_back('\n');
// 返回结果
if (!SendAll(fd, resp))
{
CloseClient(fd, selector);
return;
}
}
// 防止客户端一直发送没有换行的超长请求
if (pending.size() > 8192)
{
fprintf(
stderr,
"request line too long\n"
);
CloseClient(fd, selector);
}
}
private:
// 服务器监听地址
std::string ip_;
// 服务器监听端口
uint16_t port_;
// 每个客户端对应一个未完成请求缓冲区
std::unordered_map<int, std::string> inputs_;
};
二十二、逐步分析Selector类
上面的代码较长,但select相关逻辑主要集中在:
cpp
class Selector
中。
我们先把它拆开理解。
22.1 构造函数
cpp
Selector() : max_fd_(-1)
{
FD_ZERO(&master_);
}
为什么需要初始化:
cpp
FD_ZERO(&master_);
因为:
cpp
fd_set
不能假定初始内容都是0。
如果没有初始化,位图可能包含不符合预期的数据。
因此必须清空集合。
为什么:
cpp
max_fd_ = -1;
因为:
text
当前还没有需要监视的文件描述符
所以暂时没有有效最大fd。
22.2 Add函数
cpp
bool Add(int fd)
{
if (fd < 0 || fd >= FD_SETSIZE)
{
return false;
}
FD_SET(fd, &master_);
if (fd > max_fd_)
{
max_fd_ = fd;
}
return true;
}
例如:
cpp
selector.Add(3);
表示:
text
将fd3加入select读集合
如果继续:
cpp
selector.Add(5);
那么:
text
max_fd_ = 5
这里需要检查:
cpp
fd >= FD_SETSIZE
防止使用超出集合范围的描述符编号。
22.3 Del函数
cpp
FD_CLR(fd, &master_);
负责从集合删除描述符。
例如:
text
原来:
fd3、fd4、fd5
删除:
text
fd5
以后:
text
剩余:
fd3、fd4
最大fd需要从:
text
5
变成:
text
4
因此需要重新计算。
22.4 Wait函数
核心代码:
cpp
fd_set temp = master_;
为什么复制?
因为:
select会修改传入的集合。
然后:
cpp
int ret = select(
max_fd_ + 1,
&temp,
nullptr,
nullptr,
nullptr
);
等待任意一个被监视描述符满足读就绪条件。
返回以后:
cpp
for (int fd = 0; fd <= max_fd_; ++fd)
{
if (FD_ISSET(fd, &temp))
{
ready->push_back(fd);
}
}
找出:
text
已经读就绪的描述符
最终通过:
cpp
std::vector<int>
返回给服务器。
这里最关键的设计是:
text
master_保存长期监视集合
text
temp保存本次select的结果
22.5 为什么不直接把整个TcpSocket对象复制到集合中?
对于自定义Socket封装类,需要先明确:
text
谁拥有文件描述符?
谁负责close?
对象复制是否复制所有权?
如果多个TcpSocket对象简单复制同一个fd,并且各自都在析构时执行close,可能造成重复关闭。
当前版本让Selector只保存:
cpp
int fd;
不负责关闭Socket。
Socket关闭操作由:
cpp
TcpSelectServer
集中管理。
这样更容易理解文件描述符的生命周期。
二十三、逐步分析TcpSelectServer
现在继续分析服务器类。
23.1 创建监听Socket
cpp
int listen_fd = socket(
AF_INET,
SOCK_STREAM,
0
);
这里:
cpp
AF_INET
表示IPv4。
cpp
SOCK_STREAM
表示字节流Socket。
对于TCP网络服务,通常使用:
text
AF_INET + SOCK_STREAM
组合。
23.2 绑定端口
cpp
bind(listen_fd, ...);
用于绑定服务器的:
text
IP地址
端口号
例如:
text
127.0.0.1:9090
23.3 开始监听
cpp
listen(listen_fd, 16);
使Socket进入监听状态。
23.4 为什么监听Socket也要加入select?
因为我们不仅需要监视:
text
客户端有没有发送数据
还需要监视:
text
有没有新的客户端连接
因此:
cpp
selector.Add(listen_fd);
非常重要。
如果没有加入监听描述符:
text
select不会替我们报告监听Socket的读就绪状态
23.5 为什么新客户端也要加入select?
当:
cpp
accept()
成功时,会返回新的:
cpp
client_fd
例如:
text
client_fd = 4
此时必须调用:
cpp
selector.Add(client_fd);
因为:
text
监听Socket负责接收新连接
而:
text
client_fd负责接收这个客户端的业务数据
这两个Socket的用途不同。
23.6 如果客户端断开怎么办?
在适当的TCP关闭场景下:
cpp
recv()
返回:
text
0
说明接收到了EOF。
这时需要:
cpp
selector.Del(fd);
然后:
cpp
close(fd);
为什么要先从select删除?
因为:
text
这个描述符已经不应该继续被监视
如果关闭以后没有从监视集合删除,下一轮select可能遇到无效描述符。
例如:
text
EBADF
另外,文件描述符编号可能被后续新连接复用。
因此应该明确管理:
text
描述符加入
描述符移除
描述符关闭
的过程。
二十四、完整实现dict_server.cpp
现在编写字典服务器的业务入口。
文件:
text
dict_server.cpp
完整代码:
cpp
#include "select_server.hpp"
#include <unordered_map>
int main()
{
// 1. 定义字典
const std::unordered_map<
std::string,
std::string
> dict = {
{"hello", "你好"},
{"world", "世界"},
{"apple", "苹果"},
{"linux", "Linux操作系统"},
{"select", "I/O多路转接"}
};
// 2. 定义业务处理函数
Handler handler = [&dict](
const std::string& req,
std::string* resp
)
{
auto it = dict.find(req);
if (it != dict.end())
{
*resp = it->second;
}
else
{
*resp = "not found";
}
};
// 3. 创建select服务器
TcpSelectServer server(
"127.0.0.1",
9090
);
// 4. 启动服务
return server.Start(handler) ? 0 : 1;
}
24.1 为什么使用unordered_map?
因为需要通过:
text
英文单词
快速查找:
text
对应中文释义
例如:
cpp
dict.find("hello");
如果找到:
text
你好
就返回对应结果。
24.2 为什么业务函数使用Handler?
因为我们希望:
text
TcpSelectServer只负责网络通信
而:
text
Handler负责具体业务
这样以后如果需要将字典业务换成其他业务,例如:
text
简单计算器
字符串处理
关键字查询
只需要更换Handler。
不必重新修改整套select事件循环。
24.3 为什么监听127.0.0.1?
因为:
text
127.0.0.1
是IPv4环回地址。
这里只需要在本机测试。
如果要允许其他设备通过相应网络接口访问,需要根据实际环境修改监听地址并配置网络访问规则。
对于教学实验,优先选择:
text
127.0.0.1
更容易控制测试范围。

二十五、编译运行TCP字典服务器
文件结构:
text
select-server/
│
├── select_server.hpp
│
└── dict_server.cpp
25.1 编译
在Ubuntu终端执行:
bash
g++ -std=c++11 -Wall -Wextra \
dict_server.cpp \
-o dict_server
25.2 启动
bash
./dict_server
预期输出:
text
listening on 127.0.0.1:9090
表示服务器已经启动。
25.3 使用nc客户端测试
打开第二个终端:
bash
nc 127.0.0.1 9090
输入:
text
hello
按回车。
服务器返回:
text
你好
继续输入:
text
apple
返回:
text
苹果
输入:
text
unknown
返回:
text
not found
25.4 为什么必须按回车?
因为我们设计的应用协议是:
text
每条请求以\n结束
服务器需要收到:
cpp
'\n'
才认为一条请求完整。
因此直接输入:
text
hello
但不发送换行时,服务器可能继续等待完整请求。
25.5 同时打开多个客户端
现在打开三个终端。
客户端A:
bash
nc 127.0.0.1 9090
客户端B:
bash
nc 127.0.0.1 9090
客户端C:
bash
nc 127.0.0.1 9090
它们都连接同一个服务器。
假设:
text
客户端A输入:
hello
收到:
text
你好
客户端B输入:
text
world
收到:
text
世界
客户端C输入:
text
select
收到:
text
I/O多路转接
这就说明:
一个select事件循环可以管理多个客户端连接。
25.6 测试一批连续请求
也可以通过终端命令一次发送多条请求。
例如:
bash
printf 'hello\nworld\napple\n' | nc -N 127.0.0.1 9090
在支持 nc -N 的Ubuntu环境中,可以得到:
text
你好
世界
苹果
这个测试还能够帮助验证:
服务器是否正确按换行符分割多条TCP请求。
如果系统中的nc实现不支持 -N,可以使用普通交互式nc测试。
二十六、整个select事件循环究竟发生了什么?
现在我们结合三个客户端,重新梳理服务器的完整流程。
假设:
text
监听Socket:
fd3
客户端A连接以后:
text
fd4
客户端B连接以后:
text
fd5
客户端C连接以后:
text
fd6
那么:
text
master集合:
fd3、fd4、fd5、fd6
26.1 第一轮:没有客户端数据
服务器执行:
cpp
selector.Wait(&ready);
内部调用:
cpp
select(maxfd + 1, ...);
即:
cpp
select(7, ...);
假设:
text
所有客户端暂时没有数据
那么线程可以在select中等待。
26.2 第二轮:客户端B发送数据
客户端B:
text
fd5
收到:
text
hello\n
此时:
text
fd5满足读就绪条件
select返回。
临时集合中:
text
fd5对应位置被设置
服务器通过:
cpp
FD_ISSET(5, &temp)
识别就绪描述符。
随后:
cpp
HandleClient(5, ...);
调用:
cpp
recv(5, ...);
取得客户端B的数据。
26.3 第三轮:客户端A和C同时发送
假设:
text
fd4可读
fd6可读
select返回以后:
text
ready = {4, 6}
服务器依次处理:
text
客户端A
客户端C
26.4 第四轮:客户端C断开连接
假设:
cpp
recv(6, ...);
返回:
text
0
那么服务器执行:
cpp
selector.Del(6);
再关闭:
cpp
close(6);
此时主集合变为:
text
fd3、fd4、fd5
如果原来的最大fd是:
text
6
则需要重新计算:
text
maxfd = 5
下一次调用:
cpp
select(6, ...);
这样就完成了一个简单的多客户端事件循环。

二十七、这个服务器还有哪些需要注意的地方?
前面的程序能够作为select的入门实验,但我们还需要清楚它的适用范围。
27.1 select只负责报告就绪
我们使用:
cpp
select()
检测读就绪。
它不会自动帮我们调用:
cpp
accept()
或者:
cpp
recv()
这些操作仍然需要应用程序完成。
27.2 TCP仍然是字节流
所以:
text
一次recv
不一定对应:
text
一次send
本例通过:
text
\n
划分应用层请求。
这是为了避免错误地依赖TCP消息边界。
27.3 当前服务器使用阻塞式发送
例如:
cpp
SendAll(fd, resp);
内部会循环调用:
cpp
send()
直到响应全部发送或发生错误。
这样能够处理部分发送的问题。
但是,如果某个客户端一直不读取数据,发送缓冲区耗尽时,当前线程仍然可能在 send() 中等待。
这会影响其他客户端的响应。
因此:
使用select管理读事件,并不意味着整个服务器已经完全没有阻塞点。
对于更完善的高并发服务器,通常还需要:
text
非阻塞Socket
写就绪事件
每客户端发送缓冲区
部分发送处理
等机制。
这些内容可以在后续学习完整事件驱动服务器时继续展开。
27.4 select返回后,操作就一定成功吗?
不一定。
例如:
text
select报告fd可读
随后:
cpp
recv()
仍然必须检查返回值。
因为可能出现:
text
正常读取
EOF
错误
连接状态变化
等情况。
27.5 为什么不能简单复制Socket对象?
如果自定义TcpSocket类管理文件描述符的关闭,而多个对象又共享同一个fd,就必须正确处理对象的所有权。
否则可能出现:
text
重复close
错误关闭其他连接
描述符复用导致的问题
本篇的Selector只保存描述符编号,避免把Socket对象所有权管理与select集合管理混在一起。
27.6 如果客户端数量很多怎么办?
select存在:
text
FD_SETSIZE限制
每次复制描述符集合
内核检查描述符集合
用户程序扫描就绪位图
等问题。
因此它适合学习I/O多路转接原理,也适合某些规模有限的网络程序。
但如果目标是大规模高并发服务器,就需要继续研究其他事件通知机制。
二十八、select常见面试问题
28.1 select是什么?
select是一种I/O多路转接接口。
它能够同时监视多个文件描述符的可读、可写及特定异常条件,并在相应条件满足或超时等情况下返回。
28.2 select和阻塞recv有什么区别?
阻塞recv主要针对一个指定Socket执行读取并等待结果。
select可以同时等待多个描述符的就绪条件。
28.3 select是否属于异步I/O?
在经典五种I/O模型中:
不是。
select只负责报告就绪,应用程序仍然需要调用recv执行读取。
28.4 select的第一个参数是什么?
cpp
nfds
表示:
需要监视的最大文件描述符编号加1。
28.5 FD_ZERO是什么?
用于清空fd_set集合。
28.6 FD_SET是什么?
用于将指定文件描述符加入集合。
28.7 FD_ISSET是什么?
用于检查指定描述符在集合中对应的位是否被设置。
28.8 为什么每次调用select前要重建fd_set?
因为select会修改传入的集合,只保留相应就绪的描述符位。
28.9 select返回0是什么意思?
在设置了timeout的情况下:
表示超时,未检测到对应就绪事件。
28.10 为什么监听Socket读就绪要调用accept?
因为监听Socket读就绪通常表示有连接等待接受。
需要使用accept获取新的已连接Socket。
28.11 为什么客户端Socket读就绪后recv可能返回0?
因为TCP对端正常结束发送时,Socket可能报告读就绪,而recv返回EOF。
28.12 为什么select要维护maxfd?
因为:
cpp
select(maxfd + 1, ...);
需要根据最大描述符确定检查范围。
28.13 select最多支持多少个Socket?
不能简单给出一个与平台无关的固定客户端数量。
常见Linux用户态接口中:
cpp
FD_SETSIZE = 1024
限制fd_set能够表示的描述符编号范围。
由于还存在标准文件描述符、监听Socket及其他文件,能够管理的客户端数量通常少于这一数字。
28.14 select最大的缺点是什么?
主要包括:
text
fd_set容量限制
每次调用需要重新准备集合
用户态与内核态之间的集合传递开销
内核检查描述符的成本
应用程序查找就绪fd的成本
因此在大规模连接场景中存在局限性。
二十九、全文总结
本文系统学习了Linux中的:
select I/O多路转接机制。
首先从阻塞recv与非阻塞轮询的问题出发,认识了select的核心作用:
让一个执行流同时等待多个文件描述符的就绪事件。
随后学习了函数原型:
cpp
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout
);
其中最重要的几个参数是:
text
nfds:
最大fd + 1
text
readfds:
监视读就绪
text
writefds:
监视写就绪
text
timeout:
控制等待时间
接下来详细分析了:
cpp
fd_set
的位图思想。
以及四个核心宏:
cpp
FD_ZERO
FD_SET
FD_CLR
FD_ISSET
其中必须记住:
select会修改传入的fd_set,因此每轮需要重新准备描述符集合。
随后分析了Socket的读写就绪条件。
例如:
text
接收缓冲区有数据
可能导致读就绪。
text
对端正常结束发送
也可能导致读就绪。
text
监听Socket存在待接受连接
同样可能导致读就绪。
因此:
读就绪不等于recv一定返回正数。
之后分析了select的主要局限:
text
fd_set容量限制
集合反复复制
描述符检查成本
应用程序扫描就绪位图
最后完成了两个实验。
第一个实验:
使用select监听标准输入。
第二个实验:
使用select同时管理多个TCP客户端,实现字典服务器。
在服务器中,我们设计:
cpp
Selector
负责:
text
Add
Del
Wait
并设计:
cpp
TcpSelectServer
负责:
text
创建监听Socket
接受客户端连接
读取客户端请求
调用业务Handler
发送响应
关闭连接
最终形成:
text
select等待
|
v
监听Socket就绪?
|
+---- 是 → accept新客户端
|
+---- 否 → 客户端Socket就绪
|
v
recv
|
v
业务处理
|
v
send
到这里,我们就能够从接口、位图、事件机制以及完整程序实现四个方面理解select。
接下来继续学习:
poll与epoll。
就能够进一步分析不同I/O多路转接机制在描述符管理、就绪事件通知和大规模并发场景中的区别。