文章目录
- @[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_set?
- 4.3 select 版服务器的执行流程
- 五、阻塞式 vs select 对比分析
- 5.1 阻塞点对比
- 5.2 相同场景的对比
- 六、select 的优势总结
- 七、select 的局限性
- 八、编译与测试
- 九、总结
文章目录
- @[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_set?
- 4.3 select 版服务器的执行流程
- 五、阻塞式 vs select 对比分析
- 5.1 阻塞点对比
- 5.2 相同场景的对比
- 六、select 的优势总结
- 七、select 的局限性
- 八、编译与测试
- 九、总结
深入理解 select I/O 多路复用:从阻塞到并发的演进之路
一、引言
在网络编程中,最基本的通信模型就是"阻塞式 I/O"。然而,当我们需要同时处理多个连接时,阻塞式 I/O 的局限性就暴露无遗。本文将通过对比阻塞式与select 多路复用两种实现方式,深入剖析阻塞点的位置、select 的工作原理,以及它为何能解决并发问题。
二、阻塞式实现(代码一 & 代码四)
2.1 服务器端代码(阻塞式)
#include<arpa/inet.h>#include<cstring>#include<iostream>#include<sys/socket.h>#include<unistd.h>usingnamespacestd;intmain(){// 1. 创建 TCP 套接字intfd=socket(AF_INET,SOCK_STREAM,0);if(fd==-1){cerr<<"socket() failed"<<endl;return-1;}// 2. 设置端口复用intopt=1;if(setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt))==-1){cerr<<"setsockopt() failed"<<endl;close(fd);return-1;}// 3. 绑定地址和端口sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_addr.s_addr=htonl(INADDR_ANY);addr.sin_port=htons(8888);if(bind(fd,(sockaddr*)&addr,sizeof(addr))==-1){cerr<<"bind() failed (port 8888 may be in use)"<<endl;close(fd);return-1;}// 4. 开始监听if(listen(fd,5)==-1){cerr<<"listen() failed"<<endl;close(fd);return-1;}cout<<"Server listening on port 8888..."<<endl;// 5. 接受客户端连接sockaddr_in client_addr{};socklen_t len=sizeof(client_addr);intcfd=accept(fd,(sockaddr*)&client_addr,&len);if(cfd==-1){cerr<<"accept() failed"<<endl;close(fd);return-1;}// 6. 输出客户端信息charip_str[INET_ADDRSTRLEN];inet_ntop(AF_INET,&client_addr.sin_addr,ip_str,sizeof(ip_str));cout<<"Client connected from "<<ip_str<<":"<<ntohs(client_addr.sin_port)<<endl;// 7. 读取客户端消息charbuf[64]={};ssize_t n=read(cfd,buf,sizeof(buf)-1);if(n==-1){cerr<<"read() failed"<<endl;}elseif(n==0){cout<<"Client closed connection"<<endl;}else{buf[n]='\0';cout<<"Received: "<<buf<<endl;}// 8. 发送回复消息constchar*msg="Hello from server";ssize_t sent=write(cfd,msg,strlen(msg));if(sent==-1){cerr<<"write() failed"<<endl;}// 9. 关闭连接close(cfd);close(fd);return0;}2.2 客户端代码(阻塞式)
#include<arpa/inet.h>#include<cstring>#include<iostream>#include<sys/socket.h>#include<unistd.h>usingnamespacestd;intmain(){intfd=socket(AF_INET,SOCK_STREAM,0);sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_addr.s_addr=inet_addr("127.0.0.1");addr.sin_port=htons(8888);connect(fd,(sockaddr*)&addr,sizeof(addr));cout<<"Connected to server"<<endl;constchar*msg="Hello from client";write(fd,msg,strlen(msg));cout<<"Sent: "<<msg<<endl;charbuf[64]={};read(fd,buf,sizeof(buf));cout<<"Received: "<<buf<<endl;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 版本)
#include<arpa/inet.h>#include<sys/select.h>#include<sys/socket.h>#include<unistd.h>#include<algorithm>#include<cstring>#include<iostream>#include<string>#include<vector>usingnamespacestd;intmain(){// 1. 创建 TCP 套接字intfd=socket(AF_INET,SOCK_STREAM,0);if(fd==-1){cerr<<"socket() failed"<<endl;return-1;}// 2. 设置端口复用intopt=1;setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));// 3. 绑定并监听sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_addr.s_addr=htonl(INADDR_ANY);addr.sin_port=htons(8888);if(bind(fd,(sockaddr*)&addr,sizeof(addr))==-1||listen(fd,5)==-1){cerr<<"bind/listen failed"<<endl;close(fd);return-1;}cout<<"Server listening on port 8888..."<<endl;// 4. 初始化 select 相关变量fd_set master_fds,read_fds;FD_ZERO(&master_fds);FD_SET(fd,&master_fds);intmax_fd=fd;vector<int>clients;while(true){read_fds=master_fds;intactivity=select(max_fd+1,&read_fds,nullptr,nullptr,nullptr);if(activity<0){perror("select");break;}// ---- 处理新连接 ----if(FD_ISSET(fd,&read_fds)){sockaddr_in client_addr{};socklen_t len=sizeof(client_addr);intcfd=accept(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));cout<<"New connection from "<<ip_str<<":"<<ntohs(client_addr.sin_port)<<endl;FD_SET(cfd,&master_fds);max_fd=max(max_fd,cfd);clients.push_back(cfd);}}// ---- 处理客户端数据 ----for(autoit=clients.begin();it!=clients.end();){intcfd=*it;if(FD_ISSET(cfd,&read_fds)){string buf;buf.resize(64);ssize_t n=read(cfd,buf.data(),buf.size());if(n<=0){cout<<"Client disconnected (fd="<<cfd<<")"<<endl;close(cfd);FD_CLR(cfd,&master_fds);it=clients.erase(it);}else{buf.resize(n);cout<<"Received: "<<buf<<endl;conststring reply="Hello from server";write(cfd,reply.c_str(),reply.size());++it;}}else{++it;}}}close(fd);return0;}3.2 客户端代码(select 版本)
#include<arpa/inet.h>#include<sys/select.h>#include<sys/socket.h>#include<unistd.h>#include<cstring>#include<iostream>#include<string>usingnamespacestd;intmain(){intfd=socket(AF_INET,SOCK_STREAM,0);sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_addr.s_addr=inet_addr("127.0.0.1");addr.sin_port=htons(8888);if(connect(fd,(sockaddr*)&addr,sizeof(addr))==-1){cerr<<"connect() failed"<<endl;return-1;}cout<<"Connected to server. Type messages (Ctrl+D 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_fd=max(STDIN_FILENO,fd);intactivity=select(max_fd+1,&read_fds,nullptr,nullptr,nullptr);if(activity<0)break;// ---- 标准输入有数据:读取并发送给服务器 ----if(FD_ISSET(STDIN_FILENO,&read_fds)){string buf;buf.resize(256);ssize_t n=read(STDIN_FILENO,buf.data(),buf.size());if(n<=0){cout<<"Exiting..."<<endl;break;}buf.resize(n);write(fd,buf.data(),buf.size());cout<<"Sent: "<<buf;}// ---- 服务器有数据:读取并打印 ----if(FD_ISSET(fd,&read_fds)){string buf;buf.resize(256);ssize_t n=read(fd,buf.data(),buf.size());if(n<=0){cout<<"Server closed connection"<<endl;break;}buf.resize(n);cout<<"Received: "<<buf<<endl;}}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_fd+1,&fds,NULL,NULL,NULL);// 💥 第二次循环:listen_fd 已不在 fds 中// 服务器永远无法接受新连接!}4.3 select 版服务器的执行流程
┌──────────────────────────────────────────────────────────────┐ │ 主循环 while(true) │ │ │ │ ① read_fds = master_fds // 复制完整监控列表 │ │ │ │ ② select(max_fd+1, &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++-std=c++17-oserver_select server_select.cpp g++-std=c++17-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 体系的钥匙。