I/O多路复用

I/O 多路复用 = 一个线程 / 进程,同时监听多个 I/O 对象的状态变化

通过一个系统调用,让内核同时监视多个文件描述符(fd),
当其中任意一个 fd 满足条件(可读 / 可写 / 异常)时,返回通知用户态

为什么需要 I/O 多路复用?

Linux 中的 I/O 特性

  • read / write 默认是阻塞的
  • socket / pipe / eventfd / timerfd 本质上都是 fd

常见错误模型

模型 问题
一个 fd 一个线程 线程爆炸、上下文切换高
while + 非阻塞轮询 CPU 空转
阻塞 read 顺序读 谁慢卡死谁

I/O 多路复用解决了什么?

I/O 多路复用做了三件事:

  1. 把“等待”交给内核
  2. 让进程只在“有事发生”时被唤醒
  3. 一次返回“已经就绪的 fd 集合”

抽象模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌──────────┐
│ 用户线程 │
└────┬─────┘
│ 等待
┌────▼─────┐
│ 内核 │ ← 监听多个 fd
│ select │
│ poll │
│ epoll │
└────┬─────┘
│ 事件就绪
┌────▼─────┐
│ 返回就绪 │
│ fd │
└──────────┘

select —— 最原始的 I/O 多路复用

1️⃣ select 的工作方式

  • 用户传入:
    • 读 fd 集合
    • 写 fd 集合
    • 异常 fd 集合
  • 内核:
    • 遍历所有 fd
    • 检查状态
  • 返回:
    • 修改后的 fd 集合(哪些就绪)

2️⃣ select 的核心特点

特点 说明
fd 表示方式 位图(fd_set)
fd 上限 FD_SETSIZE(通常 1024)
内核扫描 全量扫描
数据拷贝 每次调用都拷贝

📌 时间复杂度:O(n)


3️⃣ select 的适用场景

  • 老代码维护
  • fd 数量极少(<10)
  • 教学 / 调试

poll —— select 的结构改良版

1️⃣ poll 的工作方式

  • 使用 struct pollfd[]
  • 每个 fd 自带关心的事件
  • 内核遍历整个数组

2️⃣ poll 相比 select 的改进

改进点 说明
fd 数量 理论无限
数据结构 更清晰
接口易用性 更好

3️⃣ poll 的本质问题

问题 是否解决
每次传 fd
全量扫描
O(n) 复杂度

📌 poll 是“结构改进”,不是“机制升级”


epoll —— Linux 的事件驱动模型

1️⃣ epoll 的核心思想

把 fd 注册给内核,由内核长期维护“监听列表”和“就绪队列”


2️⃣ epoll 的三步模型

① 创建 epoll 实例

1
epoll_create1(0);

② 注册 / 修改 fd

1
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

③ 等待事件

1
epoll_wait(epfd, events, maxevents, timeout);

3️⃣ epoll 为什么快?

机制 说明
红黑树 管理监听 fd
就绪链表 只存“已就绪 fd”
回调机制 fd 状态变化时主动上报
无重复拷贝 fd 不反复传入

📌 复杂度 ≈ O(就绪 fd 数量)


4️⃣ epoll 的两种触发模式

LT(水平触发,默认)

  • 数据没读完 → 一直通知
  • 安全稳定

ET(边沿触发)

  • 只通知一次
  • 必须非阻塞 + 读到 EAGAIN

5️⃣ epoll 的适用场景

场景 推荐
多 socket
IPC / Unix socket
Redis / ROS2
嵌入式 Linux

整体对比总结表(工程视角)

维度 select poll epoll
最大 fd 1024 无限 无限
性能
内核扫描 全量 全量 就绪
并发能力
Linux 推荐 ⚠️

内核扫描

一、内核扫描到底“扫”的是什么?

扫的不是数据,而是 状态

内核检查的是:

  • socket 接收队列有没有数据?
  • socket 发送缓冲区有没有空间?
  • pipe 是否可读?
  • fd 是否异常?

📌 本质是:

检查 fd 对应内核对象的状态标志


二、select / poll 中的“内核扫描”过程

poll 为例(select 本质一样):

1
poll(fds, nfds, timeout);

内核会做:

1
2
3
4
5
for (i = 0; i < nfds; i++) {
if (fds[i].fd 就绪) {
标记为 ready
}
}

关键点

  • 每一次 poll 调用
  • 每一个 fd
  • 无论它是否有数据

👉 这就叫 内核扫描


三、为什么内核扫描是性能瓶颈?

1️⃣ 时间复杂度:O(n)

  • 100 个 fd → 扫 100 次
  • 10 万个 fd → 扫 10 万次

哪怕只有 1 个 fd 真正有数据
内核也必须把 10 万个全扫一遍