I/O多路复用

select 的底层实现与缺点

核心数据结构: fd_set 位图

|--------------------------------------------------------------------------------------------|
| c typedef struct { unsigned long fds_bits__FD_SETSIZE / (8 \* sizeof(long)); } fd_set; |

  1. 容量限制:__FD_SETSIZE 通常为 1024,即最大支持文件描述符 FD 范围为 0~1023。

  2. 位图映射规则:每一个二进制 bit 对应一个文件描述符。

第 3 位为 1 → 监控 FD=3

第 5 位为 1 → 监控 FD=5

底层执行流程

用户调用核心函数:select(nfds, &readfds, &writefds, &exceptfds, timeout)

完整执行链路如下:

内核将 fd_set 集合从用户态完整拷贝到内核态;

内核遍历 0 到 nfds-1 的所有文件描述符;

逐个检查每个 FD 的等待队列与当前读写状态;

若无任何 FD 就绪且超时时间 timeout > 0,将当前进程加入所有被监控 FD 的等待队列,调用 schedule() 让出 CPU,进程进入睡眠状态;

当任意 FD 就绪时,唤醒等待队列中的休眠进程,并标记对应 FD 为就绪状态;

内核修改 fd_set 位图,仅保留就绪 FD 的 bit 位,其余位清零;

将修改后的 fd_set 从内核态拷贝回用户态;

最终返回已就绪的 FD 总数量。

select的致命缺点

|--------------|-----------------------------------------------|
| 问题 | 详细说明 |
| FD 数量限制 | 受 __FD_SETSIZE 限制,最大仅支持 1024 个文件描述符,无法适配高并发场景 |
| O(n) 线性扫描 | 每次调用都会遍历 0~nfds-1 所有 FD,即便仅监控少量连接,无效遍历开销极大 |
| 两次完整内存拷贝 | 每次调用均需要完成用户态→内核态、内核态→用户态的完整 fd_set 拷贝,系统开销高 |
| 重复注册等待队列 | 每次调用 select 都需要重新将进程加入所有监控 FD 的等待队列,重复操作冗余 |
| fd_set 被内核修改 | 调用后 fd_set 会被内核清空非就绪位,下次调用前必须重新初始化,编码繁琐 |

poll 的实现与改进

核心数据结构: pollfd 数组

|--------------------------------------------------------------------------------------------------------------------|
| c struct pollfd { int fd; // 待监控的文件描述符 short events; // 用户关注的I/O事件(POLLIN、POLLOUT等) short revents; // 内核返回的就绪事件 }; |

相比 select 的核心改进

|----------|--------------------------------------------------------------|
| 改进点 | 详细说明 |
| 无FD数量硬限制 | 底层采用链表存储监控节点,理论上可监控任意数量文件描述符,突破select 1024上限 |
| 事件读写分离 | 通过 events(用户传入监听事件)和 revents(内核返回就绪事件)分离,无需每次重新初始化监控集合 |
| 支持更多事件类型 | 新增 POLLRDHUP(对端关闭连接)、POLLERR(连接错误)、POLLHUP(挂断)等细化事件,适配更多网络场景 |

poll底层流程(精简)

整体逻辑与 select 基本一致,仅替换核心数据结构,流程如下:

将用户态 pollfd 数组拷贝至内核态;

线性遍历所有监控 FD,检测事件就绪状态;

内核修改 revents 字段,标记已就绪事件;

将 pollfd 数组拷贝回用户态,返回就绪事件数量。

|--------------------------------------------------------------------------------------------|
| 核心短板 :poll 仅解决了 select 的 FD 数量限制问题,仍存在****O(n) 线性遍历、全量内存拷贝 的性能缺陷,高并发场景下效率依旧低下。 |

epoll 的底层实现

核心数据结构

通过epoll_create 创建的每个 epoll 实例,内核会维护两大核心数据结构,支撑高性能I/O监听:

|---------------------------------------------------------------------------------------------------------------------|
| c struct eventpoll { struct rb_root rbr; // 红黑树根节点,存储所有注册的FD struct list_head rdllist; // 就绪事件双向链表 // ... 其他辅助字段 }; |

①****红黑树

用于存储所有通过 epoll_ctl(EPOLL_CTL_ADD) 注册的文件描述符及对应监听事件。

核心优势:查找、插入、删除操作时间复杂度为 O(log N),可高效管理海量FD,同时自动规避重复注册同一FD的问题。

②****就绪队列

双向链表结构,专门存储所有已经触发就绪的I/O事件。

核心优势:epoll_wait 无需遍历全部FD,直接从该队列获取就绪事件,规避无效遍历开销。

事件触发与回调机制

完整事件流程

用户调用 epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event) 注册监听事件;

内核将目标 sockfd 及监听事件封装为 epitem 节点,插入 epoll 红黑树;

在该 sockfd 的内核等待队列中,注册专属回调函数 ep_poll_callback;

网卡接收数据包,经驱动处理后,socket 内核缓冲区数据更新,触发I/O就绪事件;

内核自动调用预先注册的 ep_poll_callback 回调函数;

回调函数将对应 epitem 节点从红黑树取出,加入 epoll 实例的就绪队列 rdllist;

唤醒处于 epoll_wait 阻塞睡眠状态的用户进程;

用户调用 epoll_wait 等待事件;

内核直接从就绪队列中取出已就绪事件(最大不超过 max_events 限制);

将就绪事件拷贝至用户态数组,返回就绪事件总数量。

多事件同时到达逻辑:

时间线执行逻辑:

  • t0 时刻:FD1 数据就绪 → 触发回调 → 就绪队列:epitem1
  • t1 时刻:FD2 数据就绪 → 触发回调 → 就绪队列:epitem1, epitem2
  • t2 时刻:FD3 数据就绪 → 触发回调 → 就绪队列:epitem1, epitem2, epitem3

用户调用 epoll_wait 后:

  • 无需遍历红黑树,直接读取就绪队列数据;
  • 严格按照 FIFO 先入先出顺序返回事件;
  • 仅拷贝少量就绪事件,无冗余IO开销。

epoll 高效性全方位对比分析

|--------|--------------------------|------------------------|
| 对比项 | select / poll | epoll |
| 时间复杂度 | O(n),每次遍历所有监控FD | O(1),直接读取就绪队列 |
| 内存拷贝开销 | 每次调用全量拷贝所有FD集合 | 仅拷贝少量就绪事件,开销极低 |
| FD数量限制 | select限1024个,poll无限制但性能差 | 无硬限制,仅受系统内存约束 |
| 事件注册开销 | 每次调用均需重新注册监听 | 仅需 epoll_ctl 一次注册,永久生效 |
| 适用场景 | 低并发场景(连接数 < 1000) | 高并发场景(支持10万+并发连接) |

epoll 共享内存的历史与现状

历史: mmap 共享内存方案(已废弃)

适用版本:Linux 2.6 早期版本

核心机制 :将 epoll_create 返回的文件描述符,通过 mmap 映射至用户态空间,实现内核态与用户态内存共享。

设计目的:规避 epoll_wait 调用时的数据拷贝,实现理论上的"零拷贝"优化。

存在问题

  • 安全风险:用户态可直接操作内核内存,极易破坏内核数据结构,导致系统崩溃;
  • 实现复杂:需要额外的同步机制保证内核与用户态内存数据一致性;
  • 收益极低:实际性能提升不明显,维护成本远大于优化收益。

现状:自 Linux 2.6.9(2004年)起,内核彻底移除 epoll 的 mmap 共享内存支持,现代 epoll 已不再使用该方案。

现代 epoll 放弃共享内存的核心原因

①****精准拷贝已足够高效

epoll 仅对已就绪的少量事件进行内存拷贝,绝大多数场景下,海量连接中仅少数连接活跃,拷贝开销几乎可忽略,无需共享内存优化。例如监控10万并发连接,仅2个连接活跃,仅需拷贝2个事件数据。

②****共享内存的固有弊端

|---------|---------------------------------|
| 问题 | 详细说明 |
| 安全性隐患 | 用户态直接读写内核内存,越权操作可能导致内核数据损坏、系统宕机 |
| 数据同步复杂 | 内核与用户态共享内存需加锁同步,逻辑复杂,易产生数据不一致问题 |
| 内核维护成本高 | 额外的共享内存逻辑增加内核代码复杂度,极易引入未知bug |

③****现代 epoll 核心优化方向

  • 红黑树管理海量FD,高效增删查改;
  • 事件回调驱动机制,无轮询无效开销;
  • 精准就绪事件拷贝,最小化系统调用开销。

|----------------------------------------------------------------|
| 核心结论 :epoll 的高性能核心源于规避无效轮询****+ 最小化数据拷贝 ,而非共享内存技术。 |

水平触发 ( LT ) vs 边缘触发( ET

基本概念

水平触发 ( Level Triggered, LT ------ 默认模式

核心原理:只要文件描述符处于就绪状态(缓冲区有数据/可写),就会持续向用户态触发事件通知。

通俗类比:门铃持续响起,直到用户处理完数据、清空就绪状态。

边缘触发( Edge Triggered, ET ------ 高性能模式

核心原理:仅在文件描述符状态发生突变的瞬间触发一次事件通知,状态不变则不再触发。

通俗类比:门铃仅在状态切换瞬间响一次,无论用户是否处理数据。

两种触发模式场景对比

测试场景:socket 接收缓冲区初始存有 2KB 数据

|--------------------------|--------------------|--------------------|
| 操作步骤 | 水平触发(LT) | 边缘触发(ET) |
| 1. epoll_wait() 返回可读事件 | 触发 | 触发 |
| 2. 应用仅读取 1KB 数据,缓冲区剩余1KB | - | - |
| 3. 再次调用 epoll_wait() | 再次触发(缓冲区仍有数据,状态就绪) | 不触发(状态无变化,仍有1KB数据) |
| 4. 对端新增发送1KB数据,缓冲区变为2KB | 触发 | 触发(状态发生更新) |

核心代码示例

水平触发 ( LT )模式代码

|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| c // epoll 默认LT模式,无需额外设置标志位 struct epoll_event event; event.events = EPOLLIN; // 默认水平触发 event.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event); // 事件处理逻辑 while (1) { int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < nfds; i++) { if (eventsi.events & EPOLLIN) { // LT模式支持分次读取数据,未读完下次仍会触发 read(eventsi.data.fd, buf, sizeof(buf)); } } } |

边缘触发( ET )模式代码

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| c // ET模式必须手动设置 EPOLLET 标志,且需配合非阻塞IO struct epoll_event event; event.events = EPOLLIN | EPOLLET; // 开启边缘触发 event.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event); // 事件处理逻辑:必须循环读取至数据读完 while (1) { int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < nfds; i++) { if (eventsi.events & EPOLLIN) { // ET模式必须一次性读完所有数据,否则残留数据无法再次触发事件 while (1) { ssize_t n = read(eventsi.data.fd, buf, sizeof(buf)); if (n == -1) { // 非阻塞IO无数据,代表读取完毕 if (errno == EAGAIN || errno == EWOULDBLOCK) { break; } // 异常错误处理 perror("read error"); close(eventsi.data.fd); break; } else if (n == 0) { // 对端正常关闭连接 close(eventsi.data.fd); break; } // 处理读取到的数据 } } } } |

LT与ET模式全方位对比

|--------|-------------------|---------------------|
| 特性 | 水平触发(LT) | 边缘触发(ET) |
| 触发时机 | FD处于就绪状态就持续触发 | 仅在FD状态发生突变时触发一次 |
| 编程复杂度 | 低,支持分次读取数据,容错率高 | 高,必须配合非阻塞IO、一次性读完数据 |
| 性能表现 | 一般,存在重复触发、多余系统调用 | 优异,大幅减少系统调用次数 |
| 数据丢失风险 | 极低,未读完数据会持续触发重试 | 较高,未读完的残留数据无法再次触发事件 |
| 适用场景 | 通用场景、新手开发、稳定性优先场景 | 高性能服务、高并发场景,性能优先 |

实际生产应用建议

|------------------|----------|--------------------------------|
| 业务场景 | 推荐模式 | 核心原因 |
| 新手学习、常规业务开发 | 水平触发(LT) | 编码简单、容错性强,不易出现数据丢失、事件遗漏问题 |
| 高性能核心服务器 | 边缘触发(ET) | 减少系统调用次数,最大化高并发场景下的吞吐性能 |
| 监听socket(接收新连接) | 水平触发(LT) | 避免高并发下连接请求遗漏,Nginx等主流服务默认采用该方案 |
| 数据传输socket(业务读写) | 边缘触发(ET) | 配合非阻塞IO,最大限度降低系统开销,提升并发性能 |

总结

三种 I/O 多路复用机制

|--------|--------------|-------------|-------------------|
| 核心特性 | select | poll | epoll |
| FD数量限制 | 最大1024个(硬限制) | 无硬限制 | 无硬限制(受内存约束) |
| 时间复杂度 | O(n) 全量遍历 | O(n) 全量遍历 | O(1) 就绪队列读取 |
| 内存拷贝方式 | 每次全量拷贝FD集合 | 每次全量拷贝FD集合 | 仅拷贝少量就绪事件 |
| 核心数据结构 | fd_set 位图 | pollfd 链表数组 | 红黑树 + 就绪双向链表 |
| 事件触发模式 | 仅水平触发 | 仅水平触发 | 水平触发 + 边缘触发双模式 |
| 适用场景 | 低并发、跨平台兼容场景 | 中并发、简单网络场景 | Linux高并发服务器(主流方案) |

问题解决

C10K 问题定义:单机服务器如何稳定支撑1万个并发TCP连接?

  • select:无法解决,1024FD上限+O(n)性能瓶颈,完全不满足需求;
  • poll:理论支持万级连接,但O(n)遍历开销极大,性能极差,无法落地;
  • epoll:C10K问题标准解决方案,事件驱动机制完美适配海量并发场景。

核心思想总结

|---------------|----------------------------------------------|
| 多路复用机制 | 核心设计思想 |
| select / poll | 轮询模式:用户态主动遍历所有FD,逐个询问"是否就绪",存在大量无效查询 |
| epoll | 通知模式:FD就绪后主动通过回调上报,内核统一维护就绪队列,用户态仅读取有效事件 |

|----------------------------------------------------------------------------------------------------------------------------|
| epoll 本质核心 :通过红黑树管理海量****FD + 内核回调事件通知 + 就绪队列精准返回 ,彻底从"轮询驱动"升级为"事件驱动",这是其碾压select/poll、适配高并发的根本原因。 |

进阶零拷贝 I/O 技术

io_uring(Linux 5.1+ 新一代异步IO框架)、DPDK(用户态高速网络栈)、XDP(内核快速数据包处理)

跨平台 I/O 多路复用方案

kqueue(BSD/macOS 系统)、IOCP(Windows 完成端口,Windows高并发核心方案)

主流开源框架底层实现

Nginx(epoll + ET 边缘触发,高性能反向代理)、Redis(epoll + LT 水平触发,保证稳定性)、Netty(Java NIO 多路复用模型)