欢迎光临
我们一直在努力

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多客户端字典服务器
    • 前言:为什么有了非阻塞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模型:

  • 阻塞I/O
  • 非阻塞I/O
  • 信号驱动I/O
  • I/O多路转接
  • 异步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 三种模式总结

    timeout含义
    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多路转接机制在描述符管理、就绪事件通知和大规模并发场景中的区别。


    赞(0)
    未经允许不得转载:171主机测评 » Linux网络编程:彻底理解IO多路复用select!从fd_set位图、读写就绪到手写TCP多客户端字典服务器
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址