← 返回 Visuals 首页

🔀 IO 多路复用到底是什么?

一个线程,盯住一万条连接,谁就绪就处理谁。用餐厅服务员类比 + Redis / MySQL 真实案例,把 select / poll / epoll 讲透,全程图解。

一句话钉死:IO 多路复用 = 「一个线程,同时盯着一堆文件描述符(socket),内核告诉你『谁就绪了』,你再去读 / 写谁」。 「多路」= 多条连接;「复用」= 一个线程被多条连接复用。它不是让 IO 变快,而是让少量线程就能扛住海量空闲连接

🍽️ 餐厅服务员类比(最直观)

🙋‍♂️

阻塞 IO(每客一服务员)

每个客人配一个服务员,站着干等他点单。100 桌 = 100 个服务员,大部分在发呆。内存爆炸、调度崩溃。

✗ 不 scalable

🏃

非阻塞忙轮询

一个服务员不停挨桌问「你点了没」。客人没点也在空转,CPU 烧穿,但没人闲着。

✗ 浪费 CPU

🔔

IO 多路复用(epoll)

一个服务员在门口等叫号:哪桌举手(socket 就绪)就去哪桌。平时只阻塞在「等叫号」上,绝不为某桌空等。

✓ 一个顶一万

问题的来源:为什么需要它

服务端要同时服务成千上万客户端。但绝大多数连接大部分时间是空闲的(等着用户打字、等请求到来)。如果给每个连接配一个线程(传统阻塞 IO),会出现两个致命问题:

🔧 交互:连接数 → 线程数 的差距

拖动滑块看「每连接一线程」和「IO 多路复用」在同样连接数下的线程开销差异。

并发连接数10,000

每连接一线程(BIO)

10,000
≈ 80 GB 栈内存

IO 多路复用(epoll)

1~几个
1 个事件循环线程 + 少量 worker

注:多路复用也不是「永远只有 1 个线程」——Redis 用 1 个主线程跑事件循环;现代服务常配「1 个多路复用线程 + N 个 worker 线程」处理 CPU 密集部分。关键是线程数不再随连接数线性增长

很多人把「IO 多路复用」和「异步 IO」混为一谈。关键区别:多路复用是同步——内核只告诉你「谁就绪了」,真正的 read/write 还是你自己调、还是会阻塞一点点;而异步 IO 是内核把数据拷贝完再通知你,你完全不用自己读

四种经典 IO 模型

同步 · 阻塞

① 阻塞 IO(BIO)

调 read() 就卡住,直到数据到、读完才返回。最简单,但一个连接占一个线程。

同步 · 非阻塞

② 非阻塞忙轮询

read() 立刻返回(没数据就报错),你得不停循环去问。CPU 空转浪费。

同步 · 多路复用

③ IO 多路复用 ★

select/poll/epoll 一次盯多个 fd,阻塞在「等就绪」上,就绪后自己 read。本题主角。

异步(AIO)

④ 异步 IO

你交待「读完通知我」,内核全程搞定(含拷贝),完事回调。Linux 上由 io_uring 实现。

维度① 阻塞 BIO② 非阻塞轮询③ 多路复用④ 异步 AIO
谁阻塞read 调用全程阻塞不阻塞,但 CPU 空转阻塞在「等就绪」全程不阻塞
谁读数据自己 read自己 read自己 read(就绪后)内核读好再通知
线程模型1 连接 1 线程1 线程轮询全部1 线程盯全部 + worker提交后干别的
典型 API传统 socket readfcntl O_NONBLOCKselect/poll/epollio_uring / IOCP
代表使用者早期简单服务几乎不用Redis / Nginx / MySQL 线程池高性能存储引擎

记忆口诀:「阻塞——卡着等;轮询——空转问;多路——等内核喊『谁好了』再去读;异步——内核全干完喊你拿结果。」多路复用和异步的鸿沟就在「读数据这步到底谁做」

多路复用的具体实现有三代武器:select → poll → epoll(Linux)/ kqueue(BSD/macOS)。点下面卡片看每一代的原理与缺陷。
📻

select(1983,跨平台)

用三个位图(读/写/异常)传 fd 集合,内核遍历找出就绪的。

点开看缺陷 →
📋

poll(改进的 select)

改用数组 pollfd,去掉 1024 硬上限,但遍历本质没变。

点开看缺陷 →

epoll(Linux 2.6,主角)

事件驱动:内核维护就绪链表,只返回「真的就绪」的 fd。

点开看优势 →

select:最早的多路复用,但有三宗罪

  • FD_SETSIZE 上限 1024:默认最多监听 1024 个 fd,改起来很麻烦。
  • 每次调用都要把整个 fd 集合从用户态拷贝进内核:连接多时拷贝开销大。
  • 返回后仍需 O(n) 遍历:内核只告诉你「有就绪」,没告诉你「谁就绪」,你自己逐个检查。(而且内核侧也是轮询所有 fd)
// 每次都要重建并拷贝 fd_set,再全量遍历 fd_set read_fds; FD_ZERO(&read_fds); FD_SET(sockfd, &read_fds); select(maxfd+1, &read_fds, NULL, NULL, &timeout); // 返回后不知道谁就绪,得自己 for 一遍

poll:松了绑,但没治病

  • 去掉 1024 上限:用动态数组 struct pollfd,想监听多少都行。
  • 但核心没变:每次仍要把整个数组拷进内核;返回后仍要 O(n) 遍历找就绪 fd;fd 很多时随连接数线性变慢。

所以 select/poll 是「随着连接数 N 增长,开销 O(N)」——连得多但活跃的少时,白遍历一大票空闲连接。

epoll:事件驱动,只关心「就绪的」

  • 三个系统调用epoll_create(建句柄)→ epoll_ctl(增删改要监听的 fd,只做一次)→ epoll_wait(等就绪,O(1))。
  • fd 集合只注册一次:不用每次拷贝,内核用红黑树存监听集合。
  • 只返回就绪的 fd:内核用「就绪链表」存,epoll_wait 直接拿,无需遍历全量。
  • 支持边缘触发(ET):状态变化才通知一次,效率更高(Redis 用更稳的水平触发 LT)。

结论:select/poll 是 O(N) 全量扫描;epoll 是 O(1) 等通知。这就是为什么高并发服务(Redis、Nginx)清一色 epoll/kqueue。

三代横向对比

特性selectpollepoll
最大 fd 数1024 硬上限无硬上限无硬上限
每次调用拷贝全量拷贝进内核全量拷贝仅注册时一次
找就绪的复杂度O(N) 全遍历O(N) 全遍历O(1) 只取就绪
触发方式水平触发水平触发LT / ET 都支持
跨平台几乎全平台几乎全平台仅 Linux(BSD 用 kqueue)
epoll 之所以是 O(1),靠内核里的两大数据结构 + 一个回调:一棵红黑树存「所有在监听的 fd」,一个就绪链表存「当前就绪的 fd」,网卡数据到了时内核回调把 fd 塞进就绪链表。

🌳 红黑树(监听集合)

fd 3 (client A)
fd 5 (client B)
fd 8 (client C)
fd 11 (listen)

epoll_ctl 增删改,
增删查 O(log n)

✅ 就绪链表(只装就绪的)

fd 5 可读 ✓
fd 11 新连接 ✓

epoll_wait 直接取走,
无需遍历红黑树

网卡中断回调机制:当某 socket 收到数据,网卡发中断 → 内核协议栈处理 → 触发 epoll 回调 → 把该 fd 挂到就绪链表。应用调 epoll_wait 时被唤醒,拿到「就绪链表」里那两三个 fd,再去 read。

🔧 epoll 调用生命周期(步进动画)

1

epoll_create

内核创建一个 epoll 实例,返回句柄(也是一个 fd)。背后初始化红黑树 + 就绪链表。

2

epoll_ctl(ADD)

把要监听的 socket fd 注册进红黑树(设感兴趣事件:可读/可写)。每个连接只注册一次。

3

epoll_wait(阻塞等待)

线程在此休眠,不占 CPU,直到有 fd 就绪才被唤醒。这是「多路复用」节省 CPU 的核心。

4

网卡中断 → 回调

某 socket 来数据,内核把它挂到就绪链表,唤醒 epoll_wait。

5

取走就绪 fd

epoll_wait 返回只含就绪 fd 的小数组(如 [fd5, fd11]),无需遍历全量。

6

read / 处理 / 回到 wait

对就绪 fd 调 read 读数据、执行业务,处理完重新进入 epoll_wait,循环往复。

Redis 是多路复用最经典的教科书案例。它用一个单线程事件循环(源码 ae.c`),在 Linux 上底层就是 epoll,把「万级客户端连接」和「命令执行」全串在这一个线程里高效跑完。
客户端连接 ×N
上万个 socket
(大部分时间空闲)
单线程事件循环
ae.c + epoll/kqueue
谁就绪处理谁
命令执行
单线程串行跑
无锁、无竞争
# Redis 事件循环(伪代码,ae.c 简化) while (1) { // 1. 阻塞在 epoll_wait,等哪个客户端就绪 events = epoll_wait(epfd, ...); // 2. 逐个处理就绪事件(读命令 / 写回) for (e in events) handle(e); // 3. 处理定时事件(过期 key、RDB 等) processTimeEvents(); }

Redis 用多路复用带来的好处

  • 单线程无锁:所有命令串行执行,根本不需要加锁,不存在并发竞争——简单且快。
  • 一个线程顶万级连接:靠 epoll 只处理就绪连接,空闲连接零成本。
  • CPU 不浪费:没事件时 epoll_wait 休眠,不空转。

⚠️ 一个常见误解:「Redis 完全单线程」

准确说:命令执行是单线程的(保证原子性、无锁);但 Redis 6.0(2020)起,网络 IO 的读 / 写可以开多个 I/O 线程io-threads),用来并行做「从 socket 读字节 / 往 socket 写字节」这种纯搬运活,读上来 / 写下去之后再交给主线程执行命令。连接监听和事件分发依然靠那个 epoll 多路复用主循环。
MySQL 是另一个角度的好例子:它默认「不是」epoll 模型,而是「每连接一线程」,所以才需要 Thread Pool 来补。对比 Redis,你能看清多路复用在架构里的不同落点。

MySQL 默认:每连接一线程(one-thread-per-connection)

连接 1
专属线程 T1
连接 2
专属线程 T2
连接 3
专属线程 T3
… 连接 N
专属线程 TN
(线程爆炸)

每个连上来的客户端,MySQL 从线程缓存里分一个专属线程全程服务它(解析 SQL、执行、回包)。连接少时没问题;但短连接 + 高并发时,线程数随连接数暴涨,上下文切换和内存开销又把前面说的老问题引爆。

MySQL 的解法:Thread Pool(线程池插件)

MySQL Enterprise / MariaDB 提供 Thread Pool 插件,把「每连接一线程」换成「少量 worker 线程 + 连接分组」。它的网络层就用上了多路复用(poll/epoll):少数线程盯着全部连接,谁就绪就把活丢给空闲 worker 去执行 SQL。

全部连接 ×N
被多路复用监听
监听线程
poll/epoll 等就绪
分发事件
worker 线程池
固定少数线程
执行 SQL

Redis vs MySQL:多路复用的两种落点

对比RedisMySQL(默认 / Thread Pool)
连接模型单线程事件循环 + epoll默认每连接一线程 / 线程池用多路复用
命令执行单线程串行(无锁)worker 多线程并行执行 SQL
多路复用位置贯穿全程(连接 + 执行都在这线程)只在「网络监听 / 分发」层,执行交给 worker
为何这样设计内存操作极快,单线程足够且免锁SQL 执行重、可能阻塞,需多线程榨干 CPU
一句话「多路复用 + 单线程」典范「多路复用做接入 + 线程池做执行」

核心差异一句话:Redis 用多路复用把「接入」和「干活」都收进一个线程(因为活儿轻);MySQL 用多路复用只管「接入分发」,把重的 SQL 执行交给一池 worker 线程(因为活儿重、要并行)。两者都靠多路复用解决「万个空闲连接不占资源」的问题。

一句话收尾:IO 多路复用 = 让一个线程通过内核(epoll/kqueue)同时盯住海量 fd,只处理就绪的那些,从而把「线程数」和「连接数」解耦——这是 Redis、Nginx、MySQL 线程池等所有高并发服务的底层支柱。

核心速查

  • 本质:一个线程复用去服务多路连接,靠内核告知「谁就绪」。
  • 三代武器:select(O(N) 全扫、1024 上限)→ poll(去上限仍全扫)→ epoll/kqueue(O(1) 只返回就绪)。
  • epoll 内核三件套:红黑树(监听集合)+ 就绪链表(就绪 fd)+ 中断回调。
  • LT vs ET:水平触发(一直通知,没读完下次还通知)/ 边缘触发(状态变才通知一次,须一次读净)。Redis 用 LT,Nginx 用 ET。
  • 多路复用 ≠ 异步:前者同步、自己 read;后者内核读好再通知(io_uring / IOCP)。
  • Redis:单线程事件循环 + epoll,命令执行也单线程(6.0+ 网络 IO 可多线程)。
  • MySQL:默认每连接一线程;Thread Pool 用多路复用做接入分发 + worker 池做 SQL 执行。

三个常见误解

「多路复用 = 异步 IO」

错。多路复用是同步的,内核只报就绪,read 还是你自己调;异步 IO 是内核把数据拷完再叫你。

「epoll 万能,线程越少越好」

错。epoll 解决「接入海量空闲连接」;若业务是 CPU 密集,还得配 worker 线程并行(见 MySQL)。单线程不等于高性能。

「Redis 完全单线程」

错。命令执行单线程,但 6.0+ 网络读写可多线程;且持久化、惰性删除等另有后台线程。

记忆口诀:「多路复用 = 一个线程看万线,内核喊谁就绪谁才动;select 全扫、epoll 只取就绪;Redis 单线程串全程,MySQL 池里分活给众工。」