文章目录[TOC]深入理解 select I/O 多路复用从阻塞到并发的演进之路一、引言二、阻塞式实现代码一 代码四2.1 服务器端代码阻塞式2.2 客户端代码阻塞式2.3 阻塞点分析2.4 致命场景演示三、select 多路复用实现代码二 代码三3.1 服务器端代码select 版本3.2 客户端代码select 版本四、select 的核心机制详解4.1 select 到底做了什么4.2 为什么需要两个 fd_set4.3 select 版服务器的执行流程五、阻塞式 vs select 对比分析5.1 阻塞点对比5.2 相同场景的对比六、select 的优势总结七、select 的局限性八、编译与测试九、总结深入理解 select I/O 多路复用从阻塞到并发的演进之路一、引言在网络编程中最基本的通信模型就是阻塞式 I/O。然而当我们需要同时处理多个连接时阻塞式 I/O 的局限性就暴露无遗。本文将通过对比阻塞式与select 多路复用两种实现方式深入剖析阻塞点的位置、select 的工作原理以及它为何能解决并发问题。二、阻塞式实现代码一 代码四2.1 服务器端代码阻塞式#includearpa/inet.h#includecstring#includeiostream#includesys/socket.h#includeunistd.husingnamespacestd;intmain(){// 1. 创建 TCP 套接字intfdsocket(AF_INET,SOCK_STREAM,0);if(fd-1){cerrsocket() failedendl;return-1;}// 2. 设置端口复用intopt1;if(setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt))-1){cerrsetsockopt() failedendl;close(fd);return-1;}// 3. 绑定地址和端口sockaddr_in addr{};addr.sin_familyAF_INET;addr.sin_addr.s_addrhtonl(INADDR_ANY);addr.sin_porthtons(8888);if(bind(fd,(sockaddr*)addr,sizeof(addr))-1){cerrbind() failed (port 8888 may be in use)endl;close(fd);return-1;}// 4. 开始监听if(listen(fd,5)-1){cerrlisten() failedendl;close(fd);return-1;}coutServer listening on port 8888...endl;// 5. 接受客户端连接sockaddr_in client_addr{};socklen_t lensizeof(client_addr);intcfdaccept(fd,(sockaddr*)client_addr,len);if(cfd-1){cerraccept() failedendl;close(fd);return-1;}// 6. 输出客户端信息charip_str[INET_ADDRSTRLEN];inet_ntop(AF_INET,client_addr.sin_addr,ip_str,sizeof(ip_str));coutClient connected from ip_str:ntohs(client_addr.sin_port)endl;// 7. 读取客户端消息charbuf[64]{};ssize_t nread(cfd,buf,sizeof(buf)-1);if(n-1){cerrread() failedendl;}elseif(n0){coutClient closed connectionendl;}else{buf[n]\0;coutReceived: bufendl;}// 8. 发送回复消息constchar*msgHello from server;ssize_t sentwrite(cfd,msg,strlen(msg));if(sent-1){cerrwrite() failedendl;}// 9. 关闭连接close(cfd);close(fd);return0;}2.2 客户端代码阻塞式#includearpa/inet.h#includecstring#includeiostream#includesys/socket.h#includeunistd.husingnamespacestd;intmain(){intfdsocket(AF_INET,SOCK_STREAM,0);sockaddr_in addr{};addr.sin_familyAF_INET;addr.sin_addr.s_addrinet_addr(127.0.0.1);addr.sin_porthtons(8888);connect(fd,(sockaddr*)addr,sizeof(addr));coutConnected to serverendl;constchar*msgHello from client;write(fd,msg,strlen(msg));coutSent: msgendl;charbuf[64]{};read(fd,buf,sizeof(buf));coutReceived: bufendl;close(fd);return0;}2.3 阻塞点分析阻塞式代码中存在三个致命阻塞点它们像三道关卡一样让程序只能一条路走到黑阻塞点① ─── accept() │ │ 没有客户端连接时程序在这里永久等待 │ 即使有其他事情要做也无法处理 ▼ 阻塞点② ─── read() │ │ 客户端连接了但没发数据程序在这里永久等待 │ 此时即使有第二个客户端请求连接也无法响应 ▼ 阻塞点③ ─── 只能处理一个客户端 │ │ 处理完一个客户端后立即退出 │ 完全无法服务后续连接 ▼ 程序结束阻塞点函数阻塞条件后果①accept()没有新客户端发起连接程序卡死什么都做不了②read()客户端已连接但未发送数据程序卡死无法接受新连接③整体架构单线程串行处理处理完一个客户端后程序退出2.4 致命场景演示场景两个客户端同时访问服务器时间线 T0 服务器启动阻塞在 accept() T1 客户端A连接 → accept返回 → 得到 cfd_A T2 客户端B连接 → 进入 listen 队列等待 T3 服务器阻塞在 read(cfd_A, ...) 等待A的数据 ⚠️ 此时B已经在等待但服务器完全无法感知 T4 客户端A发送数据 → read返回 T5 服务器回复A → 关闭 cfd_A → 关闭 fd → 程序退出 客户端B从头到尾没被服务过 服务器已经退出B的连接被丢弃场景客户端连接后迟迟不发数据T0 客户端A连接 → accept返回 T1 服务器进入 read(cfd_A, ...) ⚠️ A没有发送任何数据 T2 服务器无限期阻塞在 read() 客户端B、C、D... 全部无法连接 服务器实质上已经假死三、select 多路复用实现代码二 代码三3.1 服务器端代码select 版本#includearpa/inet.h#includesys/select.h#includesys/socket.h#includeunistd.h#includealgorithm#includecstring#includeiostream#includestring#includevectorusingnamespacestd;intmain(){// 1. 创建 TCP 套接字intfdsocket(AF_INET,SOCK_STREAM,0);if(fd-1){cerrsocket() failedendl;return-1;}// 2. 设置端口复用intopt1;setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));// 3. 绑定并监听sockaddr_in addr{};addr.sin_familyAF_INET;addr.sin_addr.s_addrhtonl(INADDR_ANY);addr.sin_porthtons(8888);if(bind(fd,(sockaddr*)addr,sizeof(addr))-1||listen(fd,5)-1){cerrbind/listen failedendl;close(fd);return-1;}coutServer listening on port 8888...endl;// 4. 初始化 select 相关变量fd_set master_fds,read_fds;FD_ZERO(master_fds);FD_SET(fd,master_fds);intmax_fdfd;vectorintclients;while(true){read_fdsmaster_fds;intactivityselect(max_fd1,read_fds,nullptr,nullptr,nullptr);if(activity0){perror(select);break;}// ---- 处理新连接 ----if(FD_ISSET(fd,read_fds)){sockaddr_in client_addr{};socklen_t lensizeof(client_addr);intcfdaccept(fd,(sockaddr*)client_addr,len);if(cfd!-1){charip_str[INET_ADDRSTRLEN];inet_ntop(AF_INET,client_addr.sin_addr,ip_str,sizeof(ip_str));coutNew connection from ip_str:ntohs(client_addr.sin_port)endl;FD_SET(cfd,master_fds);max_fdmax(max_fd,cfd);clients.push_back(cfd);}}// ---- 处理客户端数据 ----for(autoitclients.begin();it!clients.end();){intcfd*it;if(FD_ISSET(cfd,read_fds)){string buf;buf.resize(64);ssize_t nread(cfd,buf.data(),buf.size());if(n0){coutClient disconnected (fdcfd)endl;close(cfd);FD_CLR(cfd,master_fds);itclients.erase(it);}else{buf.resize(n);coutReceived: bufendl;conststring replyHello from server;write(cfd,reply.c_str(),reply.size());it;}}else{it;}}}close(fd);return0;}3.2 客户端代码select 版本#includearpa/inet.h#includesys/select.h#includesys/socket.h#includeunistd.h#includecstring#includeiostream#includestringusingnamespacestd;intmain(){intfdsocket(AF_INET,SOCK_STREAM,0);sockaddr_in addr{};addr.sin_familyAF_INET;addr.sin_addr.s_addrinet_addr(127.0.0.1);addr.sin_porthtons(8888);if(connect(fd,(sockaddr*)addr,sizeof(addr))-1){cerrconnect() failedendl;return-1;}coutConnected to server. Type messages (CtrlD to quit):endl;fd_set read_fds;while(true){FD_ZERO(read_fds);FD_SET(STDIN_FILENO,read_fds);FD_SET(fd,read_fds);intmax_fdmax(STDIN_FILENO,fd);intactivityselect(max_fd1,read_fds,nullptr,nullptr,nullptr);if(activity0)break;// ---- 标准输入有数据读取并发送给服务器 ----if(FD_ISSET(STDIN_FILENO,read_fds)){string buf;buf.resize(256);ssize_t nread(STDIN_FILENO,buf.data(),buf.size());if(n0){coutExiting...endl;break;}buf.resize(n);write(fd,buf.data(),buf.size());coutSent: buf;}// ---- 服务器有数据读取并打印 ----if(FD_ISSET(fd,read_fds)){string buf;buf.resize(256);ssize_t nread(fd,buf.data(),buf.size());if(n0){coutServer closed connectionendl;break;}buf.resize(n);coutReceived: bufendl;}}close(fd);return0;}四、select 的核心机制详解4.1 select 到底做了什么select的本质是一个同步的、批量的、破坏性的就绪状态过滤器。函数签名 int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); 参数说明 nfds → 监控的最大 fd 1 readfds → 输入我关心哪些fd的可读事件 输出被修改为只有真正可读的fd writefds → 输入/输出可写事件本例未使用 exceptfds → 输入/输出异常事件本例未使用 timeout → 超时时间传nullptr表示永久等待4.2 为什么需要两个 fd_set这是理解 select 最关键的一点。select会原地修改传入的fd_set将未就绪的 fd 全部清除。master_fds主副本永不传给 select 作用记录我应该监控哪些 fd 生命周期整个程序运行期间 read_fds工作副本每次传给 select 作用作为 select 的输入/输出缓冲区 生命周期仅本轮循环有效流程图┌───────────────────────────────────────────────┐ │ master_fds完整监控列表 │ │ { listen_fd, client_3, client_7 } │ └───────────────────┬───────────────────────────┘ │ 每轮循环开头read_fds master_fds ▼ ┌───────────────────────────────────────────────┐ │ read_fds工作副本 │ │ { listen_fd, client_3, client_7 } │ └───────────────────┬───────────────────────────┘ │ 传入 select() ▼ ┌───────────────────────────────────────────────┐ │ select 返回后 read_fds 被内核修改 │ │ { , client_3, } │ │ ↑ 只有 client_3 就绪其余被清零 │ └───────────────────┬───────────────────────────┘ │ 用 FD_ISSET 检查每个 fd │ 处理完后丢弃 read_fds ▼ 回到循环顶部如果只用一个 fd_set 会发生什么// ❌ 错误示范fd_set fds;FD_SET(listen_fd,fds);FD_SET(client_fd,fds);while(true){// 第一次 select 后假设只有 client_fd 就绪// fds 中 listen_fd 被清除了select(max_fd1,fds,NULL,NULL,NULL);// 第二次循环listen_fd 已不在 fds 中// 服务器永远无法接受新连接}4.3 select 版服务器的执行流程┌──────────────────────────────────────────────────────────────┐ │ 主循环 while(true) │ │ │ │ ① read_fds master_fds // 复制完整监控列表 │ │ │ │ ② select(max_fd1, read_fds) // 阻塞等待事件 │ │ │ │ │ ├─ 无任何事件 → 继续阻塞或超时返回 │ │ └─ 有事件发生 → read_fds 被修改返回就绪数量 │ │ │ │ ③ FD_ISSET(listen_fd, read_fds) ? │ │ ├─ YES → accept() 新连接 │ │ │ FD_SET(new_cfd, master_fds) // 加入监控列表 │ │ │ clients.push_back(new_cfd) │ │ └─ NO → 跳过 │ │ │ │ ④ 遍历 clients 中每个 cfd: │ │ FD_ISSET(cfd, read_fds) ? │ │ ├─ YES → read(cfd, buf) │ │ │ ├─ n 0: 客户端断开 │ │ │ │ close(cfd) │ │ │ │ FD_CLR(cfd, master_fds) // 从监控列表移除 │ │ │ │ clients.erase(...) │ │ │ └─ n 0: 处理数据回复客户端 │ │ └─ NO → 跳过该客户端本轮无数据 │ │ │ │ ⑤ 回到 ①开始下一轮 │ └──────────────────────────────────────────────────────────────┘五、阻塞式 vs select 对比分析5.1 阻塞点对比对比项阻塞式select 版accept 阻塞没有连接时永久卡死select 统一管理不单独阻塞read 阻塞没数据时永久卡死只在确认有数据时才 read不会阻塞多客户端只能处理一个同时监控多个轮流处理新连接到来时正在 read 就无法感知下一轮 select 即可感知客户端不发数据服务器假死不影响其他客户端5.2 相同场景的对比场景两个客户端同时访问服务器═══ 阻塞式服务器 ═══ T0 启动阻塞在 accept() T1 客户端A连接 → accept返回 T2 客户端B连接 → 进入listen队列 T3 阻塞在 read(cfd_A) ← 等A的数据 B在队列中无人理睬 T4 A发数据 → 回复A → 关闭 → 退出 B被彻底丢弃 ═══ select 服务器 ═══ T0 启动select监控 {listen_fd} T1 select返回listen_fd就绪 → accept → 得到cfd_A master_fds {listen_fd, cfd_A} T2 客户端B连接 → listen队列 T3 select返回listen_fd就绪 → accept → 得到cfd_B ✅ master_fds {listen_fd, cfd_A, cfd_B} T4 select返回cfd_A就绪 → 读取A的数据 → 回复A ✅ T5 select返回cfd_B就绪 → 读取B的数据 → 回复B ✅ 所有客户端都被正常服务场景客户端连接后不发数据═══ 阻塞式服务器 ═══ T0 客户端A连接 → accept返回 T1 read(cfd_A) 阻塞 ← A没发数据 服务器假死无法接受任何新连接 ═══ select 服务器 ═══ T0 客户端A连接 → accept返回 → 加入master_fds T1 select监控 {listen_fd, cfd_A} A没发数据 → cfd_A不就绪 → select继续等待 T2 客户端B连接 → listen_fd就绪 → accept ✅ A不发数据完全不影响B的连接六、select 的优势总结优势详细说明① 消除单点阻塞不再在某一个accept()或read()上死等而是统一由select管理所有 I/O 事件② 单线程并发无需多线程/多进程一个线程即可同时处理多个客户端连接③ 资源消耗低不需要为每个连接创建线程线程栈默认 8MB内存友好④ 避免竞态条件单线程模型天然无线程安全问题无需加锁⑤ 公平调度每个就绪的 fd 都有机会被处理不会因为某个客户端慢而饿死其他客户端⑥ 客户端也受益客户端可以同时等待服务器响应和用户输入互不干扰七、select 的局限性尽管 select 解决了阻塞问题但它也有明显的缺点局限性说明fd 数量限制FD_SETSIZE默认为 1024最多监控 1024 个文件描述符O(n) 遍历开销每次调用需要遍历所有 fd即使只有 1 个就绪重复拷贝每次循环都要read_fds master_fds完整复制用户态/内核态拷贝fd_set 需要在用户空间和内核空间之间来回拷贝破坏性修改必须手动维护两份 fd_set编程模型不够直观进阶方向在 Linux 生产环境中推荐使用epoll替代 select。epoll 使用红黑树管理 fd就绪通知为 O(1) 复杂度且没有 1024 的数量限制。八、编译与测试# 编译g-stdc17-oserver_select server_select.cpp g-stdc17-oclient_select client_select.cpp# 运行服务器./server_select# 新开多个终端运行客户端./client_select ./client_select ./client_select# 在每个客户端中输入消息观察服务器的并发处理能力九、总结从阻塞式到 select 多路复用核心变化只有一句话把在一个 fd 上死等变成在所有 fd 上统一轮询谁就绪就处理谁。select虽然是最古老的 I/O 多路复用方案但它是理解poll、epoll、kqueue等现代机制的基石。掌握了 select 的工作原理就掌握了理解整个异步 I/O 体系的钥匙。