4.IO多路复用-select
IO多路复用
IO 多路复用是一种让单个线程(或少量线程)能够同时监听多个文件描述符(File Descriptor,FD,比如网络连接、文件、管道)的技术,当某个 FD 有可读 / 可写事件发生时,系统会通知程序去处理对应的 IO 操作。
它解决的核心问题是:避免线程 / 进程阻塞在单个 IO 操作上,提升程序处理大量并发 IO 的能力(比如高并发的服务器)。
IO 多路复用的三种核心实现(Linux 下)
| 方式 | 特点 | 适用场景 |
|---|---|---|
| select | 监听的 FD 数量有限(默认 1024),每次调用需要拷贝 FD 集合,效率低 | 兼容性好,低并发场景 |
| poll | 突破 FD 数量限制,但仍需遍历所有 FD,高并发下效率一般 | 中等并发,需要兼容多系统 |
| epoll | 基于事件驱动,只处理有事件的 FD,无 FD 数量上限,效率最高 | 高并发场景(如百万连接) |
select
函数原型
select 是 IO 多路复用的基础实现,内部基于线性表实现的,核心作用是委托内核批量检测多个文件描述符(fd)的读写 / 异常状态,且具备跨平台特性(Linux/Mac/Windows 均支持)。
内核检测的核心是文件描述符对应的缓冲区状态:
- 读就绪:读缓冲区有数据(可读取)
- 写就绪:写缓冲区有空闲容量(可写入)
- 异常就绪:读写缓冲区出现异常(如连接中断、数据错误)
内核完成检测后,会通过 select 的参数将 “就绪的文件描述符” 按类型(读 / 写 / 异常)分到 3 个集合中返回,程序员只需处理这些就绪 fd 即可,无需对所有 fd 轮询。
1 |
|
参数详解
参数:
nfds:- 内核遍历 fd 集合的终止条件(需传入 “最大 fd 值 + 1”),若是不知道最大的fd值可以填1024,因为
select能检测的最大的文件描述符的个数是1024 - Windows 下无效,填 - 1 即可
- 内核遍历 fd 集合的终止条件(需传入 “最大 fd 值 + 1”),若是不知道最大的fd值可以填1024,因为
readfds- 传入传出参数:传入待检测读状态的 fd 集合,返回仅包含读就绪的 fd 集合;必填
writefds- 传入传出参数:传入待检测写状态的 fd 集合,返回仅包含写就绪的 fd 集合;无需则填 NULL
exceptfds- 传入传出参数:传入待检测异常的 fd 集合,返回仅包含异常的 fd 集合;无需则填 NULL
timeout阻塞控制:
NULL → 永久阻塞,直到有 fd 就绪
非 NULL(
tv_sec/tv_usec>0)→ 超时后解除阻塞,返回 0tv_sec=tv_usec=0 → 非阻塞,立即返回
这个结构体里两个参数秒和微秒都必须初始化,因为总结果是秒和微秒相加,如果不初始化将会是随机数
返回值
| 返回值 | 含义 |
|---|---|
| > 0 | 成功,返回所有集合中就绪的 fd 总数(读 + 写 + 异常) |
| = -1 | 失败,可通过errno查看错误原因(如被信号中断、参数非法) |
| = 0 | 超时,无任何 fd 就绪 |
fd_set
fd_set 是系统提供的文件描述符集合类型,本质是一个位图(bit array) —— 集合中的每一个二进制位对应一个文件描述符,通过 “位的 0/1 状态” 标记该 fd 是否被加入集合。
1 | sizeof(fd_set) = 128 字节 * 8 = 1024 bit // int [32] 32个整型数,但不是按照整形来处理的 |
在 Linux 系统中,fd_set 的底层定义(简化版)如下:
1 | // 头文件:sys/select.h |
- 例如:fd=3 对应位图的第 3 位,fd=100 对应位图的第 100 位;
- 位为
1→ 该 fd 被加入集合(待检测);位为0→ 该 fd 不在集合中。
fd_set 核心操作函数
所有操作函数本质都是对 fd_set 位图的位操作,下面结合底层逻辑解释每个函数的作用:
| 函数原型 | 功能描述 | 底层位操作逻辑(示例) |
|---|---|---|
void FD_ZERO(fd_set *set); |
清空整个集合,所有位设为 0 | 遍历 fds_bits 数组,将每个元素赋值为 0 |
void FD_SET(int fd, fd_set *set); |
将 fd 加入集合,对应位设为 1 | 计算 fd 所在的数组下标 i = fd / __NFDBITS,偏移位 b = fd % __NFDBITS;将 set->fds_bits[i] 的第 b 位设为 1 |
void FD_CLR(int fd, fd_set *set); |
将 fd 移出集合,对应位设为 0 | 同上,将 set->fds_bits[i] 的第 b 位设为 0 |
int FD_ISSET(int fd, fd_set *set); |
判断 fd 是否在集合中(位是否为 1),返回 1(在)/0(不在) | 同上,读取 set->fds_bits[i] 的第 b 位,返回位值 |
select 函数完整工作原理(分阶段拆解)
select 的核心是用户进程委托内核批量检测文件描述符(fd)的就绪状态,整个过程分为「用户态准备」「内核态检测」「用户态处理」三个阶段,以下是逐阶段的详细解析:
一、阶段 1:用户态准备(调用 select 前的操作)
在调用 select() 前,程序员需要完成 fd 集合的初始化和参数准备,这是内核能正确检测的前提:
1.1 初始化 fd_set 集合
通过 FD_ZERO/FD_SET 构建待检测的 fd 集合(读 / 写 / 异常),本质是构建用户态的位图(每个位对应一个 fd,1 表示待检测,0 表示不检测)。
1 | // 示例:初始化读集合,加入服务器fd和客户端fd |
1.2 确定 nfds 参数
计算待检测 fd 的最大值 + 1(如最大 fd 是 10,则 nfds=11),这个值是内核遍历位图的终止条件(避免遍历无意义的高位)。
1.3 准备超时时间(可选)
初始化 struct timeval 结构体,决定 select 的阻塞策略:
timeout=NULL:永久阻塞,直到有 fd 就绪;timeout={1, 0}:阻塞 1 秒后超时;timeout={0, 0}:非阻塞,立即返回。
1.4 拷贝原始集合到临时集合
由于内核会修改传入的 fd_set 集合(仅保留就绪 fd 的位为 1),因此需拷贝原始集合到临时集合(如 tmp_fds = read_fds),避免原始集合被破坏。
二、阶段 2:内核态处理(select 函数的核心执行逻辑)
当用户进程调用 select() 后,会触发系统调用(从用户态切换到内核态),内核开始执行以下步骤:
步骤 1:参数校验与权限检查
内核首先检查:
nfds 是否合法(不能超过
FD_SETSIZE);fd 是否有效(未关闭、有读写权限);
超时时间是否合法(tv_sec/tv_usec 非负)。
若参数非法,直接返回 -1,设置
errno为对应错误码(如EINVAL)。
步骤 2:用户态 fd 集合拷贝到内核态
内核会把用户态传入的 readfds/writefds/exceptfds 三个位图,完整拷贝到内核空间(这是 select 性能差的第一个原因:高并发下拷贝开销大)。
步骤 3:遍历检测 fd 就绪状态(核心)
当调用 select(maxfd+1, &rdtemp, NULL, NULL, NULL) 时,内核会做两件事:
- 检查
rdtemp中所有文件描述符(fd)的状态; - 清空
rdtemp,仅保留触发了事件的 fd 对应的位(置为 1)。
内核以 0 ~ nfds-1 为范围,线性遍历每个 fd,逐个检测其对应的缓冲区状态
是否为就绪状态
- 读集合:读缓冲区有数据
- 写集合:写缓冲区可写
- 异常集合:文件描述符有异常
检测规则(以读就绪为例,写 / 异常类似)
| fd 类型 | 读就绪判定条件 |
|---|---|
| 监听 socket | 有新的客户端连接(完成三次握手,accept 非阻塞) |
| 已连接 socket | 读缓冲区有数据(可读取);或客户端关闭连接(读缓冲区收到 FIN 包);或连接重置 |
| 普通文件 fd | 读指针未到文件末尾(有数据可读取) |
遍历过程(伪代码)
1 | // 内核遍历逻辑(简化版) |
⚠️ 关键:这是 select 性能差的第二个原因 ——fd 越多,遍历耗时越长(线性时间复杂度 O (n))。
步骤 4:阻塞等待(若暂无就绪 fd)
若遍历后无任何 fd 就绪,内核会:
将当前进程加入等待队列(对应 fd 的等待队列,如 socket 的读等待队列);
切换进程状态为 “睡眠态”(放弃 CPU 使用权),直到满足以下条件之一:
- 某个 fd 就绪(如客户端发数据,内核唤醒进程);
- 超时时间到达(内核定时器触发);
- 进程收到信号(如 Ctrl+C,中断阻塞)。
步骤 5:内核态结果回写
当有 fd 就绪或超时 / 中断后,内核会:
若有就绪 fd:将内核态的 fd_set 位图(仅保留就绪 fd 的位为 1)拷贝回用户态的临时集合(如
tmp_fds);若超时 / 中断:清空用户态的 fd_set 位图(所有位为 0);
设置 select 的返回值:
- 就绪数 > 0:返回就绪 fd 总数;
- 超时:返回 0;
- 中断 / 错误:返回 -1。
三、阶段 3:用户态处理(select 返回后的操作)
select 从内核态返回用户态后,程序员需要处理就绪的 fd:
步骤 1:判断返回值
- 返回 -1:处理错误(如被信号中断则重新调用 select);
- 返回 0:处理超时(如重新调用 select);
- 返回 >0:遍历临时集合找就绪 fd。
步骤 2:遍历临时集合,定位就绪 fd
通过 FD_ISSET 逐个检查临时集合中的 fd(从 0 到 nfds-1),判断哪些 fd 对应的位为 1(就绪):
1 | // 示例:遍历读集合找就绪 fd |
⚠️ 关键:这是 select 性能差的第三个原因 —— 用户态仍需线性遍历才能找到就绪 fd,无直接的就绪列表。
步骤 3:清理无效 fd(可选)
若某个 fd 对应的连接关闭(如 recv 返回 0),需通过 FD_CLR 将其从原始集合中移除,避免后续重复检测。
三、select 工作原理的核心特点(总结)
1. 核心流程

2. 性能瓶颈的根源
- 拷贝开销:每次调用 select 都要拷贝 fd 集合(用户态→内核态→用户态),fd 越多拷贝越大;
- 遍历开销:内核遍历所有待检测 fd,用户态遍历所有 fd 找就绪者,双重线性遍历;
- 数量限制:受
FD_SETSIZE限制(默认 1024),无法支持高并发。
3. 关键细节补充
- fd_set 是 “传入传出参数”:内核会修改集合,因此必须备份原始集合;
- 超时时间的修改:Linux 下内核会修改
struct timeval(记录剩余超时时间),因此重复调用 select 需重新初始化超时时间; - 阻塞的本质:select 的阻塞是 “进程睡眠”,而非空循环,因此不消耗 CPU 资源。
四、总结
- select 的核心是「用户态委托内核检测 fd 就绪状态」,全流程分为用户态准备、内核态检测、用户态处理三个阶段;
- 内核的核心操作是「拷贝 fd 集合→遍历检测→阻塞等待→回写结果」,其中 “拷贝 + 遍历” 是性能瓶颈;
- 用户态必须遍历临时集合才能找到就绪 fd,且需备份原始集合避免被内核修改,这是 select 使用的关键注意点。
理解这个流程后,你就能明白为什么 epoll 能优化 select:epoll 解决了 “重复拷贝”“线性遍历”“数量限制” 三大问题,本质是用 “红黑树 + 就绪链表” 替代了 “位图 + 线性遍历”。
基于select实现的服务器并发案例
1 |
|
一、代码核心功能
你编写的这段代码是一个基于 select 多路复用实现的TCP 回显服务器,核心能力是监听 10000 端口,同时处理多个客户端的连接请求,并将客户端发送的数据原样回显给客户端,客户端断开连接时会清理对应的资源。
二、代码逐行详细解析
1. 头文件引入
1 |
作用:引入网络编程、文件操作、多路复用所需的核心头文件,是 Linux 下 TCP 编程的基础依赖。
2. 主函数与监听套接字创建
1 | int main() |
socket():创建套接字(文件描述符),返回值lfd称为监听套接字,用于监听客户端连接;参数说明:
AF_INET:使用 IPv4 协议族;SOCK_STREAM:创建流式套接字(对应 TCP 协议,可靠、面向连接);0:使用协议族的默认协议(TCP);
3. 服务器地址结构体初始化
1 | struct sockaddr_in caddr; |
struct sockaddr_in:IPv4 专用的地址结构体,替代通用的struct sockaddr(更易操作);字段说明:
sin_family = AF_INET:指定地址类型为 IPv4,与socket()第一个参数一致;sin_port = htons(10000):设置监听端口为 10000,htons()把主机字节序转为网络字节序(网络字节序为大端,避免字节序混乱);sin_addr.s_addr = INADDR_ANY:绑定本机所有网卡的 IP 地址(如本机有 192.168.1.100、127.0.0.1 等 IP,客户端可通过任意一个 IP 连接);
4. 绑定套接字到地址和端口
1 | int bind_res = bind(lfd, (struct sockaddr*)&caddr, sizeof(caddr)); |
bind():将监听套接字lfd与指定的 IP 和端口绑定,让客户端能通过该地址找到服务器;参数说明:
- 第一个参数:监听套接字
lfd; - 第二个参数:强制转换为通用地址结构体
struct sockaddr*(历史原因,Linux 网络 API 要求); - 第三个参数:地址结构体的大小;
- 第一个参数:监听套接字
错误处理:绑定失败(如端口被占用)时,
bind()返回 -1,通过perror()打印错误原因,关闭套接字并退出程序,避免资源泄漏。
5. 开始监听端口
1 | listen(lfd, 128); |
listen():将lfd从 “主动套接字” 转为 “被动套接字”,开始监听客户端的连接请求;参数说明:
- 第一个参数:监听套接字
lfd; - 第二个参数:监听队列的最大长度(128),表示最多容纳 128 个未被
accept()处理的连接请求;
- 第一个参数:监听套接字
6. 初始化 select 相关变量
1 | int maxfd = lfd; |
fd_set:文件描述符集合,本质是位图,用于select监控多个文件描述符的事件;核心操作宏:
FD_ZERO(&rdset):清空文件描述符集合rdset,避免脏数据;FD_SET(lfd, &rdset):将监听套接字lfd加入rdset,表示要监控lfd的读事件(有新连接时触发);
maxfd:记录监控的最大文件描述符,select()需要该值确定监控范围(只需监控 0 ~ maxfd 的描述符),初始值为lfd(此时只有监听套接字);rdtemp:临时文件描述符集合,因为select()会修改传入的集合(只保留触发事件的描述符),所以每次循环都要从rdset复制到rdtemp。
7. 主循环(处理连接和数据)
1 | while(1) |
while(1):无限循环,让服务器一直运行,处理客户端请求;rdtemp = rdset:每次循环将临时集合重置为原始集合(因为select()会修改rdtemp);select():多路复用核心函数,监控文件描述符的读 / 写 / 异常事件,此处只监控读事件;参数说明:
- 第一个参数:
maxfd + 1,表示监控的文件描述符范围是 0 ~ maxfd(select()要求 + 1); - 第二个参数:
&rdtemp,传入读事件监控集合,select()返回后,该集合只保留触发读事件的描述符; - 第三 / 四个参数:
NULL,不监控写事件、异常事件; - 第五个参数:
NULL,永久阻塞,直到有描述符触发事件(也可设置超时时间);
- 第一个参数:
8. 处理新客户端连接
1 | if(FD_ISSET(lfd, &rdtemp)){ |
FD_ISSET(lfd, &rdtemp):检查监听套接字lfd是否在触发读事件的集合中(即有新客户端连接);accept():接受新连接,返回**客户端套接字cfd**(用于和该客户端收发数据);参数说明:
- 第一个参数:监听套接字
lfd; - 第二个参数:存储客户端的地址信息(IP + 端口);
- 第三个参数:客户端地址结构体的大小(传入传出参数);
- 第一个参数:监听套接字
FD_SET(cfd, &rdset):将新的客户端套接字cfd加入监控集合,select()会监控该客户端的读事件(有数据发送时触发);maxfd = cfd > maxfd ? cfd : maxfd:更新最大文件描述符,确保select()能监控到新的客户端套接字。
9. 处理客户端数据
1 | for(int i = 0; i < maxfd + 1; ++i){ |
循环遍历:从 0 到
maxfd,检查每个文件描述符是否触发读事件;i != lfd && FD_ISSET(i, &rdtemp):排除监听套接字lfd,只处理客户端套接字的读事件;核心逻辑:
char buffer[10]:创建 10 字节的缓冲区,用于存储客户端发送的数据;read(i, buffer, sizeof(buffer)):从客户端套接字i读取数据到缓冲区,返回值len是实际读取的字节数;len > 0:读取到数据,通过write(i, buffer, len)将数据原样回显给客户端;len == 0:客户端主动关闭连接(如客户端执行close()),通过FD_CLR(i, &rdset)将该描述符从监控集合中移除,再close(i)关闭套接字,释放资源;len < 0:读取数据失败(如客户端异常断开),通过perror()打印错误原因;
10. 关闭监听套接字
1 | close(lfd); |
close(lfd):关闭监听套接字,释放文件描述符资源;- 注意:由于主循环是
while(1)无限循环,该代码行实际不会执行,仅为语法完整性。
三、核心知识点总结
- select 多路复用原理:通过一个系统调用监控多个文件描述符的事件(读 / 写 / 异常),避免为每个客户端创建进程 / 线程,节省系统资源;
- 文件描述符集合操作:
FD_ZERO(清空)、FD_SET(添加)、FD_ISSET(检查)、FD_CLR(移除)是操作fd_set的核心宏; - TCP 服务器核心流程:
socket()(创建套接字)→bind()(绑定地址)→listen()(监听)→select()(监控事件)→accept()(接受连接)→read/write(收发数据)→close()(关闭套接字); - 关键细节:网络字节序转换(
htons())、地址结构体的强制转换、文件描述符的资源清理是 TCP 编程的易错点。
四、代码运行逻辑(无修改版)
- 服务器启动,创建监听套接字并绑定 10000 端口,开始监听;
- 初始化
select监控集合,仅监控监听套接字lfd; - 进入无限循环,
select阻塞等待事件触发; - 有新客户端连接时,
accept()得到客户端套接字cfd,将cfd加入监控集合; - 客户端发送数据时,
read()读取数据并write()回显; - 客户端关闭连接时,移除
cfd并关闭套接字; - 服务器持续运行,直到手动终止