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

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

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

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

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

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

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

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

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



