草莓熊Lotso个人主页❄️个人专栏:《C知识分享》 《Linux 入门到实践零基础也能懂》✨生活是默默的坚持毅力是永久的享受 博主简介文章目录前言一. 多路转接的本质把 等 和 拷贝 分开1.1 什么是 条件就绪1.2 常见多路复用方案对比二. select 函数详解2.1 参数详解2.2 返回值三. socket 就绪条件详解3.1 读就绪3.2 写就绪3.3 异常就绪选学四. select 的编程特点与缺点4.1 select 的编程特点4.2 select 的缺点五. 实战从零实现 select 服务器5.1 基础组件回顾5.2 select 服务器核心实现5.3 主函数5.4 代码解读5.5 编译运行结尾前言在上一篇文章中我们详细讲解了 Linux 下的五种 IO 模型其中IO 多路复用是目前高并发服务器的基石。它用一个线程就能同时处理成百上千个连接彻底解决了阻塞 IO一个连接一个线程 的性能瓶颈。而select作为 Linux 系统上第一个实现 IO 多路复用的系统调用虽然现在已经逐渐被更高效的epoll取代但它是理解所有多路复用技术的基础。几乎所有操作系统都实现了select接口掌握它不仅能帮你理解多路复用的核心思想也是面试中绕不开的考点。本文将从最底层的多路转接本质讲起带你彻底搞懂select函数的每一个参数最后通过实战代码从零实现一个基于select的 TCP 服务器。一. 多路转接的本质把 “等” 和 “拷贝” 分开在讲select之前我们必须先回顾一个核心结论任何 IO 操作都可以拆分为两个步骤等 拷贝。我们之前用的read、recv这些系统调用其实是把这两件事一起做了先等数据就绪再把数据从内核拷贝到用户空间。而多路转接技术的核心思想就是把这两步彻底分开等由select、poll、epoll这类专门的函数来做一次可以同时等多个文件描述符fd拷贝还是由原来的read、recv、write、send来做只有当select告诉我们某个 fd 就绪了我们才去调用这些函数换句话说多路转接就是一个就绪事件的通知机制。它告诉我们哪个 fd 已经准备好了可以进行读写操作了而我们只需要在收到通知后去执行实际的拷贝操作即可。1.1 什么是 “条件就绪”select等多路复用函数本质上就是在等待 fd 的 “条件就绪”。条件就绪分为两种读就绪内核接收缓冲区中有数据可读或者有新的连接请求到来写就绪内核发送缓冲区中有空闲空间可以写入数据当某个 fd 满足以上任意一个条件时select就会返回告诉我们这个 fd 已经就绪可以进行对应的 IO 操作了。1.2 常见多路复用方案对比Linux 系统提供了三种主流的多路复用实现它们的核心思想一致但在性能和使用方式上有很大区别方案支持系统数据结构连接数上限效率selectPOSIX 兼容Linux/Windows/macOS位图固定默认 1024低内核需线性遍历所有 fdpollLinux/macOS链表无固定上限受内存限制中仍需线性遍历epoll仅 Linux红黑树 就绪链表无固定上限高仅通知活跃 fd无需遍历虽然epoll是目前性能最好的但select作为最基础、最通用的实现是我们必须掌握的。二. select 函数详解select函数的原型如下#includesys/select.hintselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,structtimeval*timeout);这个函数看起来参数很多但其实逻辑非常清晰。下面我们逐个拆解每一个参数的含义。2.1 参数详解1. nfds最大文件描述符 1这是最容易被误解的一个参数。很多人以为它是要监听的 fd 的个数其实不是。nfds的值应该是你要监听的所有 fd 中最大的那个 fd 的值再加 1。比如你要监听 fd3、fd5、fd7 这三个 fd那么nfds应该设置为718。这样内核只需要遍历 0 到 7 这 8 个 fd而不需要遍历整个 1024 位的位图提高了效率。2. readfds、writefds、exceptfds三个文件描述符集这三个参数分别对应我们要监听的读事件、写事件和异常事件的 fd 集合。它们的类型都是fd_set本质上是一个位图结构。fd_set结构体的定义大致如下typedefstruct{longintfds_bits[__FD_SETSIZE/__NFDBITS];}fd_set;其中__FD_SETSIZE默认是 1024所以select最多只能同时监听 1024 个 fd这是它最致命的缺点之一。为了方便操作这个位图系统提供了四个宏// 清空fd_set中的所有位voidFD_ZERO(fd_set*set);// 将fd添加到fd_set中voidFD_SET(intfd,fd_set*set);// 将fd从fd_set中移除voidFD_CLR(intfd,fd_set*set);// 测试fd是否在fd_set中被置位intFD_ISSET(intfd,fd_set*set);非常重要的一点这三个参数都是输入输出型参数。输入时用户通过这三个参数告诉内核我们关心哪些 fd 上的哪些事件输出时内核通过这三个参数告诉用户哪些 fd 上的哪些事件已经就绪了也就是说每次调用select之后这三个fd_set都会被内核修改。未就绪的 fd 对应的位会被清空只有就绪的 fd 对应的位会被保留。因此每次调用**select**之前都必须重新设置这三个参数。3. timeout超时时间这个参数用来设置select的等待时间类型是struct timevalstructtimeval{time_ttv_sec;// 秒suseconds_ttv_usec;// 微秒};timeout有三种取值NULLselect会永久阻塞直到有 fd 就绪{0, 0}select会立即返回不阻塞这是非阻塞模式特定时间值select会阻塞指定的时间如果在这段时间内没有 fd 就绪就返回 0注意当select因为有 fd 就绪而返回时timeout结构体的值会被修改为剩余的时间。比如你设置了 5 秒超时结果 2 秒后就有 fd 就绪了那么返回时timeout.tv_sec会变成 3。2.2 返回值select的返回值有三种情况 0成功返回就绪的 fd 的个数 0超时在指定时间内没有任何 fd 就绪 0出错错误码存放在errno中三. socket 就绪条件详解很多人写select代码时不知道什么时候 fd 才算就绪。这里我们详细讲解一下 TCP socket 的就绪条件这是正确使用select的前提。3.1 读就绪当满足以下任意一个条件时socket 就处于读就绪状态内核接收缓冲区中的字节数大于等于低水位标记SO_RCVLOWAT默认是 1此时可以无阻塞地读取数据并且返回值大于 0TCP 通信中对端关闭了连接此时对该 socket 读会返回 0监听的 socket 上有新的连接请求到来这是服务器最核心的事件socket 上有未处理的错误3.2 写就绪当满足以下任意一个条件时socket 就处于写就绪状态内核发送缓冲区中的可用字节数大于等于低水位标记SO_SNDLOWAT默认是 1此时可以无阻塞地写入数据并且返回值大于 0socket 的写操作被关闭调用了close或shutdown对这样的 socket 写会触发SIGPIPE信号非阻塞connect连接成功或失败之后socket 上有未读取的错误3.3 异常就绪选学当 socket 上收到带外数据TCP 紧急数据时就处于异常就绪状态。这在实际开发中很少用到了解即可。四. select 的编程特点与缺点4.1 select 的编程特点通过上面的讲解我们可以总结出select编程的几个核心特点需要辅助数组保存所有要监听的 fd因为select返回后会清空未就绪的 fd所以我们需要一个额外的数据结构通常是数组来保存所有要监听的 fd每次调用select前都要从这个数组中把 fd 重新添加到fd_set中每次调用前必须重置fd_set由于fd_set是输入输出型参数每次调用后都会被内核修改所以必须在每次循环开始时重新调用FD_ZERO清空然后从辅助数组中重新添加 fd需要手动计算最大 fd每次添加或删除 fd 时都要重新计算当前最大的 fd作为nfds参数的值4.2 select 的缺点select虽然简单通用但它的缺点也非常明显支持的 fd 数量有限默认最多只能监听 1024 个 fd虽然可以通过重新编译内核来修改但这会带来兼容性问题用户态与内核态拷贝开销大每次调用select都需要把整个fd_set从用户态拷贝到内核态当 fd 很多时这个开销会非常大内核需要线性遍历所有 fd内核每次都要遍历所有传入的 fd检查是否有事件就绪当 fd 很多时效率会急剧下降接口使用不便每次调用前都要手动重置fd_set计算最大 fd非常繁琐五. 实战从零实现 select 服务器讲完了理论我们来动手实现一个基于select的 TCP 服务器。这个服务器目前只处理新连接的建立后续我们会继续完善添加客户端数据的读写功能。5.1 基础组件回顾我们会复用之前封装好的几个基础组件想看具体实现的可以翻翻我之前的博客比如网络版本计算器这个里面好像就有InetAddr.hpp封装了网络地址的转换Socket.hpp封装了 TCP socket 的创建、绑定、监听、接受连接等操作Logger.hpp线程安全的日志系统Mutex.hpp互斥锁和 RAII 锁守卫这里重点看一下Socket.hpp中新增的地址和端口复用设置voidCreateSocketOrDie()override{_sockfdsocket(AF_INET,SOCK_STREAM,0);if(_sockfd0){LOG(LogLevel::FATAL)create socket error;exit(SOCKET_ERROR);}intopt1;// 设置地址复用setsockopt(_sockfd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));// 设置端口复用setsockopt(_sockfd,SOL_SOCKET,SO_REUSEPORT,opt,sizeof(opt));LOG(LogLevel::INFO)create socket success;}添加这两个选项可以解决服务器重启时 “地址已被占用” 的问题是服务器开发中的最佳实践。5.2 select 服务器核心实现我们的selectServer类的核心代码如下#ifndef__SELECTSERVER__HPP#define__SELECTSERVER__HPP#includeLogger.hpp#includeInetAddr.hpp#includeSocket.hpp#includebits/types/struct_timeval.h#includecstdint#includememory#includesys/select.h// [核心解析]sizeof(fd_set) 通常是 128 字节128 * 8 1024。这是 select 底层位图能表示的最大比特位数。// 换句话说这也直接锁死了我们这个服务器最多只能同时托管 1024 个文件描述符包含监听套接字。#defineNUM(sizeof(fd_set)*8)usingnamespaceLogModule;constintgdefaultfd-1;classselectServer{public:selectServer(uint16_tport):_port(port),_listenfd(std::make_uniqueTcpSocket()){_listenfd-BulidSocketMethod(_port);// 初始化// [核心解析]辅助数组的作用是在用户态应用层保存所有需要被 select 监控的 fd。// 初始时全部置为 -1 (gdefaultfd)表示该槽位目前为空闲状态没有分配给任何连接。for(inti0;iNUM;i){arr_fds[i]gdefaultfd;}// 首先把_litenfd 放入数组里面// [核心解析]服务器刚启动时唯一存在的文件描述符就是监听套接字 (_listenfd)。// 必须把它作为第一个“火种”放入辅助数组的 0 号位让 select 跑起来去感知第一批新连接的到来。arr_fds[0]_listenfd-Socketfd();}voidStart(){fd_set rfds;// read fd setwhile(true){// rfds 参数重置// [核心解析]【高频考点】因为 select 返回时内核会把“未就绪”的 fd 从 rfds 中清零输入输出型参数的特性。// 如果不每次清空并重新设置下一次循环系统就再也不会去监听那些曾经未就绪的 fd 了。FD_ZERO(rfds);// [核心解析]select 的第一个参数要求传入最大 fd 的值。// 因为随着客户端的连接和断开fd 是动态变化的所以我们必须在每次循环中重新计算出当前托管的 max_fd。intmax_fdgdefaultfd;for(inti0;iNUM;i){if(arr_fds[i]gdefaultfd)continue;// 把新的添加进去FD_SET(arr_fds[i],rfds);if(max_fdarr_fds[i])max_fdarr_fds[i];// 更新最大文件描述符}// struct timeval timeout {5,0}; // 我们今天这里先不使用这个// int n select(max_fd 1, rfds, nullptr, nullptr, timeout);// [核心解析]第一个参数必须是 max_fd 1这是操作系统底层轮询位图的边界要求。// 最后一个参数设为 nullptr表示采用纯阻塞等待模式没有 fd 就绪就死等不消耗 CPU 资源。intnselect(max_fd1,rfds,nullptr,nullptr,nullptr);switch(n){case0:LOG(LogLevel::DEBUG)time out...;break;case-1:LOG(LogLevel::DEBUG)select error...;break;default:LOG(LogLevel::DEBUG)事件就绪...: n: n;// [核心解析]【重要提示】目前代码的逻辑是只要有事件就绪n 0就统统交由 HandlerEvent 去处理新连接。// 这是一个简化版的骨架。实际上在完整的逻辑中我们还需要在这里遍历 arr_fds// 利用 FD_ISSET() 去精准判断到底是 _listenfd 就绪了来新连接了还是普通的普通通信 fd 就绪了有数据发来了。HandlerEvent();break;}}}~selectServer(){}private:voidHandlerEvent(){InetAddr clientaddr;// [核心解析]这里的 Accept 绝对不会发生阻塞// 因为正是外层的 select 已经帮我们“等”到了底层三次握手完成读事件就绪所以我们一调 Accept 就能瞬间拿到新连接。intfd_listenfd-Accepter(clientaddr);LOG(LogLevel::INFO)get a new link...;// 你得到了一个新的连接这个连接怎么处理// recv(fd)?? 等 拷贝 不能// fd - 托管给select- 只有select具有等的能力!- 如何托管 - 只要把fd添加的辅助数组即可if(fd0){// 找一个空闲的位置// [核心解析]由于不能立刻 recv我们必须把这个刚刚拿到的 fd 存进辅助数组里。intpos0;for(;posNUM;pos){if(arr_fds[pos]gdefaultfd)break;}if(posNUM){// 遍历完了找不到// [核心解析]数组满了说明服务器达到了 1024 个并发连接的物理上限。// 此时心有余而力不足只能无情地关闭掉这个新建立的连接保护服务器不崩溃。close(fd);}else{// 把这个新获取到的加进辅助数组即可// 下一轮循环的时候, 会关心上的// [核心解析]逻辑完美闭环将新 fd 填入数组的空闲槽位。// 当此函数执行完毕代码流会重返外层 while(true) 的起点。// 下一次遍历辅助数组时这个崭新的 fd 就会自然而然地被 FD_SET 加入位图正式移交内核的 select 托管arr_fds[pos]fd;}}}private:uint16_t_port;std::unique_ptrSocket_listenfd;// 需要一个辅助数组intarr_fds[NUM];};#endif5.3 主函数主函数非常简单只需要创建一个selectServer对象并启动即可#includeselectServer.hpp#includecstdint#includememory// 定义服务器绑定的默认端口号constuint16_tgport8080;intmain(){// [代码解析]对象的实例化与生命周期管理// 这里使用了 C11 引入的 std::unique_ptr 智能指针来在堆区创建服务器对象。// 采用智能指针的核心优势是 RAII资源获取即初始化机制// 即使服务器在未来运行中抛出异常导致意外退出unique_ptr 也会在出作用域时// 自动调用 selectServer 的析构函数确保底层的文件描述符等系统资源被安全释放。std::unique_ptrselectServerselect_serverstd::make_uniqueselectServer(gport);// [代码解析]启动事件循环Event Loop// 调用 Start() 后主线程的代码执行流将进入 selectServer 类内部的 while(true) 死循环。// 服务器从此刻起正式开始运转持续不断地执行 重置位图 - select等待 - 处理就绪事件 的闭环逻辑。// 除非收到中断信号如 CtrlC或内部发生致命错误调用 exit()否则程序将永远阻塞运行在此处。select_server-Start();return0;}5.4 代码解读辅助数组的作用arr_fds数组是整个select服务器的核心。它保存了所有我们要监听的 fd包括监听 socket 和所有客户端连接的 socket。每次调用select前我们都要从这个数组中把所有有效 fd 重新添加到fd_set中。为什么 accept 不会阻塞因为select返回时已经告诉我们监听 socket 上有新连接到来了。此时调用accept内核中已经有完成三次握手的连接在全连接队列中等待了所以accept会立即返回不会阻塞。新连接的处理当我们得到一个新的连接 fd 后只需要把它添加到辅助数组中即可。下一次循环时这个 fd 就会被自动添加到select的监听集合中select就会开始监听这个 fd 上的读事件。5.5 编译运行编译运行这个服务器然后用多个 telnet 客户端连接你会看到服务器成功接受了所有连接结尾 我是草莓熊 Lotso若这篇技术干货帮你打通了学习中的卡点 【关注】跟我一起深耕技术领域从基础到进阶见证每一次成长 ❤️ 【点赞】让优质内容被更多人看见让知识传递更有力量 ⭐ 【收藏】把核心知识点、实战技巧存好需要时直接查、随时用 【评论】分享你的经验或疑问比如曾踩过的技术坑一起交流避坑 ️ 【投票】用你的选择助力社区内容方向告诉大家哪个技术点最该重点拆解 技术之路难免有困惑但同行的人会让前进更有方向愿我们都能在自己专注的领域里一步步靠近心中的技术目标结语本文从多路转接的本质出发详细讲解了select函数的每一个参数和工作原理并通过实战代码实现了一个能够接受多个连接的select服务器。目前这个服务器还只是一个骨架它只能接受新连接还不能处理客户端发送的数据。在下一篇文章中我们会继续完善这个服务器添加客户端数据的读写功能同时讲解如何处理客户端断开连接的情况以及如何优化select的事件处理逻辑。虽然select有很多缺点但它是理解 IO 多路复用的基础。掌握了select你再去学习poll和epoll就会非常轻松。如果你觉得这篇文章对你有帮助欢迎点赞、收藏、关注我们下期再见✨把这些内容吃透超牛的放松下吧✨ʕ˘ᴥ˘ʔづきらど
【Linux网络】深入理解Linux IO多路复用:从本质到select服务器实战
草莓熊Lotso个人主页❄️个人专栏:《C知识分享》 《Linux 入门到实践零基础也能懂》✨生活是默默的坚持毅力是永久的享受 博主简介文章目录前言一. 多路转接的本质把 等 和 拷贝 分开1.1 什么是 条件就绪1.2 常见多路复用方案对比二. select 函数详解2.1 参数详解2.2 返回值三. socket 就绪条件详解3.1 读就绪3.2 写就绪3.3 异常就绪选学四. select 的编程特点与缺点4.1 select 的编程特点4.2 select 的缺点五. 实战从零实现 select 服务器5.1 基础组件回顾5.2 select 服务器核心实现5.3 主函数5.4 代码解读5.5 编译运行结尾前言在上一篇文章中我们详细讲解了 Linux 下的五种 IO 模型其中IO 多路复用是目前高并发服务器的基石。它用一个线程就能同时处理成百上千个连接彻底解决了阻塞 IO一个连接一个线程 的性能瓶颈。而select作为 Linux 系统上第一个实现 IO 多路复用的系统调用虽然现在已经逐渐被更高效的epoll取代但它是理解所有多路复用技术的基础。几乎所有操作系统都实现了select接口掌握它不仅能帮你理解多路复用的核心思想也是面试中绕不开的考点。本文将从最底层的多路转接本质讲起带你彻底搞懂select函数的每一个参数最后通过实战代码从零实现一个基于select的 TCP 服务器。一. 多路转接的本质把 “等” 和 “拷贝” 分开在讲select之前我们必须先回顾一个核心结论任何 IO 操作都可以拆分为两个步骤等 拷贝。我们之前用的read、recv这些系统调用其实是把这两件事一起做了先等数据就绪再把数据从内核拷贝到用户空间。而多路转接技术的核心思想就是把这两步彻底分开等由select、poll、epoll这类专门的函数来做一次可以同时等多个文件描述符fd拷贝还是由原来的read、recv、write、send来做只有当select告诉我们某个 fd 就绪了我们才去调用这些函数换句话说多路转接就是一个就绪事件的通知机制。它告诉我们哪个 fd 已经准备好了可以进行读写操作了而我们只需要在收到通知后去执行实际的拷贝操作即可。1.1 什么是 “条件就绪”select等多路复用函数本质上就是在等待 fd 的 “条件就绪”。条件就绪分为两种读就绪内核接收缓冲区中有数据可读或者有新的连接请求到来写就绪内核发送缓冲区中有空闲空间可以写入数据当某个 fd 满足以上任意一个条件时select就会返回告诉我们这个 fd 已经就绪可以进行对应的 IO 操作了。1.2 常见多路复用方案对比Linux 系统提供了三种主流的多路复用实现它们的核心思想一致但在性能和使用方式上有很大区别方案支持系统数据结构连接数上限效率selectPOSIX 兼容Linux/Windows/macOS位图固定默认 1024低内核需线性遍历所有 fdpollLinux/macOS链表无固定上限受内存限制中仍需线性遍历epoll仅 Linux红黑树 就绪链表无固定上限高仅通知活跃 fd无需遍历虽然epoll是目前性能最好的但select作为最基础、最通用的实现是我们必须掌握的。二. select 函数详解select函数的原型如下#includesys/select.hintselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,structtimeval*timeout);这个函数看起来参数很多但其实逻辑非常清晰。下面我们逐个拆解每一个参数的含义。2.1 参数详解1. nfds最大文件描述符 1这是最容易被误解的一个参数。很多人以为它是要监听的 fd 的个数其实不是。nfds的值应该是你要监听的所有 fd 中最大的那个 fd 的值再加 1。比如你要监听 fd3、fd5、fd7 这三个 fd那么nfds应该设置为718。这样内核只需要遍历 0 到 7 这 8 个 fd而不需要遍历整个 1024 位的位图提高了效率。2. readfds、writefds、exceptfds三个文件描述符集这三个参数分别对应我们要监听的读事件、写事件和异常事件的 fd 集合。它们的类型都是fd_set本质上是一个位图结构。fd_set结构体的定义大致如下typedefstruct{longintfds_bits[__FD_SETSIZE/__NFDBITS];}fd_set;其中__FD_SETSIZE默认是 1024所以select最多只能同时监听 1024 个 fd这是它最致命的缺点之一。为了方便操作这个位图系统提供了四个宏// 清空fd_set中的所有位voidFD_ZERO(fd_set*set);// 将fd添加到fd_set中voidFD_SET(intfd,fd_set*set);// 将fd从fd_set中移除voidFD_CLR(intfd,fd_set*set);// 测试fd是否在fd_set中被置位intFD_ISSET(intfd,fd_set*set);非常重要的一点这三个参数都是输入输出型参数。输入时用户通过这三个参数告诉内核我们关心哪些 fd 上的哪些事件输出时内核通过这三个参数告诉用户哪些 fd 上的哪些事件已经就绪了也就是说每次调用select之后这三个fd_set都会被内核修改。未就绪的 fd 对应的位会被清空只有就绪的 fd 对应的位会被保留。因此每次调用**select**之前都必须重新设置这三个参数。3. timeout超时时间这个参数用来设置select的等待时间类型是struct timevalstructtimeval{time_ttv_sec;// 秒suseconds_ttv_usec;// 微秒};timeout有三种取值NULLselect会永久阻塞直到有 fd 就绪{0, 0}select会立即返回不阻塞这是非阻塞模式特定时间值select会阻塞指定的时间如果在这段时间内没有 fd 就绪就返回 0注意当select因为有 fd 就绪而返回时timeout结构体的值会被修改为剩余的时间。比如你设置了 5 秒超时结果 2 秒后就有 fd 就绪了那么返回时timeout.tv_sec会变成 3。2.2 返回值select的返回值有三种情况 0成功返回就绪的 fd 的个数 0超时在指定时间内没有任何 fd 就绪 0出错错误码存放在errno中三. socket 就绪条件详解很多人写select代码时不知道什么时候 fd 才算就绪。这里我们详细讲解一下 TCP socket 的就绪条件这是正确使用select的前提。3.1 读就绪当满足以下任意一个条件时socket 就处于读就绪状态内核接收缓冲区中的字节数大于等于低水位标记SO_RCVLOWAT默认是 1此时可以无阻塞地读取数据并且返回值大于 0TCP 通信中对端关闭了连接此时对该 socket 读会返回 0监听的 socket 上有新的连接请求到来这是服务器最核心的事件socket 上有未处理的错误3.2 写就绪当满足以下任意一个条件时socket 就处于写就绪状态内核发送缓冲区中的可用字节数大于等于低水位标记SO_SNDLOWAT默认是 1此时可以无阻塞地写入数据并且返回值大于 0socket 的写操作被关闭调用了close或shutdown对这样的 socket 写会触发SIGPIPE信号非阻塞connect连接成功或失败之后socket 上有未读取的错误3.3 异常就绪选学当 socket 上收到带外数据TCP 紧急数据时就处于异常就绪状态。这在实际开发中很少用到了解即可。四. select 的编程特点与缺点4.1 select 的编程特点通过上面的讲解我们可以总结出select编程的几个核心特点需要辅助数组保存所有要监听的 fd因为select返回后会清空未就绪的 fd所以我们需要一个额外的数据结构通常是数组来保存所有要监听的 fd每次调用select前都要从这个数组中把 fd 重新添加到fd_set中每次调用前必须重置fd_set由于fd_set是输入输出型参数每次调用后都会被内核修改所以必须在每次循环开始时重新调用FD_ZERO清空然后从辅助数组中重新添加 fd需要手动计算最大 fd每次添加或删除 fd 时都要重新计算当前最大的 fd作为nfds参数的值4.2 select 的缺点select虽然简单通用但它的缺点也非常明显支持的 fd 数量有限默认最多只能监听 1024 个 fd虽然可以通过重新编译内核来修改但这会带来兼容性问题用户态与内核态拷贝开销大每次调用select都需要把整个fd_set从用户态拷贝到内核态当 fd 很多时这个开销会非常大内核需要线性遍历所有 fd内核每次都要遍历所有传入的 fd检查是否有事件就绪当 fd 很多时效率会急剧下降接口使用不便每次调用前都要手动重置fd_set计算最大 fd非常繁琐五. 实战从零实现 select 服务器讲完了理论我们来动手实现一个基于select的 TCP 服务器。这个服务器目前只处理新连接的建立后续我们会继续完善添加客户端数据的读写功能。5.1 基础组件回顾我们会复用之前封装好的几个基础组件想看具体实现的可以翻翻我之前的博客比如网络版本计算器这个里面好像就有InetAddr.hpp封装了网络地址的转换Socket.hpp封装了 TCP socket 的创建、绑定、监听、接受连接等操作Logger.hpp线程安全的日志系统Mutex.hpp互斥锁和 RAII 锁守卫这里重点看一下Socket.hpp中新增的地址和端口复用设置voidCreateSocketOrDie()override{_sockfdsocket(AF_INET,SOCK_STREAM,0);if(_sockfd0){LOG(LogLevel::FATAL)create socket error;exit(SOCKET_ERROR);}intopt1;// 设置地址复用setsockopt(_sockfd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));// 设置端口复用setsockopt(_sockfd,SOL_SOCKET,SO_REUSEPORT,opt,sizeof(opt));LOG(LogLevel::INFO)create socket success;}添加这两个选项可以解决服务器重启时 “地址已被占用” 的问题是服务器开发中的最佳实践。5.2 select 服务器核心实现我们的selectServer类的核心代码如下#ifndef__SELECTSERVER__HPP#define__SELECTSERVER__HPP#includeLogger.hpp#includeInetAddr.hpp#includeSocket.hpp#includebits/types/struct_timeval.h#includecstdint#includememory#includesys/select.h// [核心解析]sizeof(fd_set) 通常是 128 字节128 * 8 1024。这是 select 底层位图能表示的最大比特位数。// 换句话说这也直接锁死了我们这个服务器最多只能同时托管 1024 个文件描述符包含监听套接字。#defineNUM(sizeof(fd_set)*8)usingnamespaceLogModule;constintgdefaultfd-1;classselectServer{public:selectServer(uint16_tport):_port(port),_listenfd(std::make_uniqueTcpSocket()){_listenfd-BulidSocketMethod(_port);// 初始化// [核心解析]辅助数组的作用是在用户态应用层保存所有需要被 select 监控的 fd。// 初始时全部置为 -1 (gdefaultfd)表示该槽位目前为空闲状态没有分配给任何连接。for(inti0;iNUM;i){arr_fds[i]gdefaultfd;}// 首先把_litenfd 放入数组里面// [核心解析]服务器刚启动时唯一存在的文件描述符就是监听套接字 (_listenfd)。// 必须把它作为第一个“火种”放入辅助数组的 0 号位让 select 跑起来去感知第一批新连接的到来。arr_fds[0]_listenfd-Socketfd();}voidStart(){fd_set rfds;// read fd setwhile(true){// rfds 参数重置// [核心解析]【高频考点】因为 select 返回时内核会把“未就绪”的 fd 从 rfds 中清零输入输出型参数的特性。// 如果不每次清空并重新设置下一次循环系统就再也不会去监听那些曾经未就绪的 fd 了。FD_ZERO(rfds);// [核心解析]select 的第一个参数要求传入最大 fd 的值。// 因为随着客户端的连接和断开fd 是动态变化的所以我们必须在每次循环中重新计算出当前托管的 max_fd。intmax_fdgdefaultfd;for(inti0;iNUM;i){if(arr_fds[i]gdefaultfd)continue;// 把新的添加进去FD_SET(arr_fds[i],rfds);if(max_fdarr_fds[i])max_fdarr_fds[i];// 更新最大文件描述符}// struct timeval timeout {5,0}; // 我们今天这里先不使用这个// int n select(max_fd 1, rfds, nullptr, nullptr, timeout);// [核心解析]第一个参数必须是 max_fd 1这是操作系统底层轮询位图的边界要求。// 最后一个参数设为 nullptr表示采用纯阻塞等待模式没有 fd 就绪就死等不消耗 CPU 资源。intnselect(max_fd1,rfds,nullptr,nullptr,nullptr);switch(n){case0:LOG(LogLevel::DEBUG)time out...;break;case-1:LOG(LogLevel::DEBUG)select error...;break;default:LOG(LogLevel::DEBUG)事件就绪...: n: n;// [核心解析]【重要提示】目前代码的逻辑是只要有事件就绪n 0就统统交由 HandlerEvent 去处理新连接。// 这是一个简化版的骨架。实际上在完整的逻辑中我们还需要在这里遍历 arr_fds// 利用 FD_ISSET() 去精准判断到底是 _listenfd 就绪了来新连接了还是普通的普通通信 fd 就绪了有数据发来了。HandlerEvent();break;}}}~selectServer(){}private:voidHandlerEvent(){InetAddr clientaddr;// [核心解析]这里的 Accept 绝对不会发生阻塞// 因为正是外层的 select 已经帮我们“等”到了底层三次握手完成读事件就绪所以我们一调 Accept 就能瞬间拿到新连接。intfd_listenfd-Accepter(clientaddr);LOG(LogLevel::INFO)get a new link...;// 你得到了一个新的连接这个连接怎么处理// recv(fd)?? 等 拷贝 不能// fd - 托管给select- 只有select具有等的能力!- 如何托管 - 只要把fd添加的辅助数组即可if(fd0){// 找一个空闲的位置// [核心解析]由于不能立刻 recv我们必须把这个刚刚拿到的 fd 存进辅助数组里。intpos0;for(;posNUM;pos){if(arr_fds[pos]gdefaultfd)break;}if(posNUM){// 遍历完了找不到// [核心解析]数组满了说明服务器达到了 1024 个并发连接的物理上限。// 此时心有余而力不足只能无情地关闭掉这个新建立的连接保护服务器不崩溃。close(fd);}else{// 把这个新获取到的加进辅助数组即可// 下一轮循环的时候, 会关心上的// [核心解析]逻辑完美闭环将新 fd 填入数组的空闲槽位。// 当此函数执行完毕代码流会重返外层 while(true) 的起点。// 下一次遍历辅助数组时这个崭新的 fd 就会自然而然地被 FD_SET 加入位图正式移交内核的 select 托管arr_fds[pos]fd;}}}private:uint16_t_port;std::unique_ptrSocket_listenfd;// 需要一个辅助数组intarr_fds[NUM];};#endif5.3 主函数主函数非常简单只需要创建一个selectServer对象并启动即可#includeselectServer.hpp#includecstdint#includememory// 定义服务器绑定的默认端口号constuint16_tgport8080;intmain(){// [代码解析]对象的实例化与生命周期管理// 这里使用了 C11 引入的 std::unique_ptr 智能指针来在堆区创建服务器对象。// 采用智能指针的核心优势是 RAII资源获取即初始化机制// 即使服务器在未来运行中抛出异常导致意外退出unique_ptr 也会在出作用域时// 自动调用 selectServer 的析构函数确保底层的文件描述符等系统资源被安全释放。std::unique_ptrselectServerselect_serverstd::make_uniqueselectServer(gport);// [代码解析]启动事件循环Event Loop// 调用 Start() 后主线程的代码执行流将进入 selectServer 类内部的 while(true) 死循环。// 服务器从此刻起正式开始运转持续不断地执行 重置位图 - select等待 - 处理就绪事件 的闭环逻辑。// 除非收到中断信号如 CtrlC或内部发生致命错误调用 exit()否则程序将永远阻塞运行在此处。select_server-Start();return0;}5.4 代码解读辅助数组的作用arr_fds数组是整个select服务器的核心。它保存了所有我们要监听的 fd包括监听 socket 和所有客户端连接的 socket。每次调用select前我们都要从这个数组中把所有有效 fd 重新添加到fd_set中。为什么 accept 不会阻塞因为select返回时已经告诉我们监听 socket 上有新连接到来了。此时调用accept内核中已经有完成三次握手的连接在全连接队列中等待了所以accept会立即返回不会阻塞。新连接的处理当我们得到一个新的连接 fd 后只需要把它添加到辅助数组中即可。下一次循环时这个 fd 就会被自动添加到select的监听集合中select就会开始监听这个 fd 上的读事件。5.5 编译运行编译运行这个服务器然后用多个 telnet 客户端连接你会看到服务器成功接受了所有连接结尾 我是草莓熊 Lotso若这篇技术干货帮你打通了学习中的卡点 【关注】跟我一起深耕技术领域从基础到进阶见证每一次成长 ❤️ 【点赞】让优质内容被更多人看见让知识传递更有力量 ⭐ 【收藏】把核心知识点、实战技巧存好需要时直接查、随时用 【评论】分享你的经验或疑问比如曾踩过的技术坑一起交流避坑 ️ 【投票】用你的选择助力社区内容方向告诉大家哪个技术点最该重点拆解 技术之路难免有困惑但同行的人会让前进更有方向愿我们都能在自己专注的领域里一步步靠近心中的技术目标结语本文从多路转接的本质出发详细讲解了select函数的每一个参数和工作原理并通过实战代码实现了一个能够接受多个连接的select服务器。目前这个服务器还只是一个骨架它只能接受新连接还不能处理客户端发送的数据。在下一篇文章中我们会继续完善这个服务器添加客户端数据的读写功能同时讲解如何处理客户端断开连接的情况以及如何优化select的事件处理逻辑。虽然select有很多缺点但它是理解 IO 多路复用的基础。掌握了select你再去学习poll和epoll就会非常轻松。如果你觉得这篇文章对你有帮助欢迎点赞、收藏、关注我们下期再见✨把这些内容吃透超牛的放松下吧✨ʕ˘ᴥ˘ʔづきらど