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

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模型:

  1. 阻塞I/O
  2. 非阻塞I/O
  3. 信号驱动I/O
  4. I/O多路转接
  5. 异步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多路转接机制在描述符管理、就绪事件通知和大规模并发场景中的区别。


相关推荐
国科安芯1 小时前
从遥控到自治:ASP4644S2B支撑星载电源健康管理与容错配电
网络·单片机·嵌入式硬件·安全·dcdc·低轨卫星·商业航天
测试界的小码农3 小时前
【Linux 命令实战(03):文件创建、复制、移动与删除,掌握 touch、mkdir、cp、mv、rm】
linux·服务器·php
|_⊙3 小时前
网络层协议 IP
服务器·网络·tcp/ip
无言听风雨丶4 小时前
Kubernetes Service Mesh 与 mTLS 实战:从网络可达走向零信任服务通信
网络·kubernetes·service_mesh
byte轻骑兵4 小时前
实战 1:修改 BlueZ 源码,添加自定义开机日志
linux·aiot·bluez·电脑蓝牙·嵌入式蓝牙
叼烟扛炮4 小时前
Linux 学习笔记(五十):网络层——IP 协议、子网划分与 NAT 魔法
linux·笔记·学习
叼烟扛炮4 小时前
Linux 学习笔记(六十):单线程的逆袭——epoll 高并发服务器实战
linux·服务器·网络·笔记·学习·算法
AR-26710-4 小时前
Linux Day32——实操五类资源告急排查
linux·运维
StarsGreen5 小时前
libva中的图像格式说明
linux
H.莓飛5 小时前
【C++】【STL】vector_OJ题
linux·服务器·开发语言·数据结构·c++·visual studio