一个线程,盯住一万条连接,谁就绪就处理谁。用餐厅服务员类比 + Redis / MySQL 真实案例,把 select / poll / epoll 讲透,全程图解。
每个客人配一个服务员,站着干等他点单。100 桌 = 100 个服务员,大部分在发呆。内存爆炸、调度崩溃。
✗ 不 scalable
一个服务员不停挨桌问「你点了没」。客人没点也在空转,CPU 烧穿,但没人闲着。
✗ 浪费 CPU
一个服务员在门口等叫号:哪桌举手(socket 就绪)就去哪桌。平时只阻塞在「等叫号」上,绝不为某桌空等。
✓ 一个顶一万
服务端要同时服务成千上万客户端。但绝大多数连接大部分时间是空闲的(等着用户打字、等请求到来)。如果给每个连接配一个线程(传统阻塞 IO),会出现两个致命问题:
拖动滑块看「每连接一线程」和「IO 多路复用」在同样连接数下的线程开销差异。
注:多路复用也不是「永远只有 1 个线程」——Redis 用 1 个主线程跑事件循环;现代服务常配「1 个多路复用线程 + N 个 worker 线程」处理 CPU 密集部分。关键是线程数不再随连接数线性增长。
调 read() 就卡住,直到数据到、读完才返回。最简单,但一个连接占一个线程。
read() 立刻返回(没数据就报错),你得不停循环去问。CPU 空转浪费。
select/poll/epoll 一次盯多个 fd,阻塞在「等就绪」上,就绪后自己 read。本题主角。
你交待「读完通知我」,内核全程搞定(含拷贝),完事回调。Linux 上由 io_uring 实现。
| 维度 | ① 阻塞 BIO | ② 非阻塞轮询 | ③ 多路复用 | ④ 异步 AIO |
|---|---|---|---|---|
| 谁阻塞 | read 调用全程阻塞 | 不阻塞,但 CPU 空转 | 阻塞在「等就绪」 | 全程不阻塞 |
| 谁读数据 | 自己 read | 自己 read | 自己 read(就绪后) | 内核读好再通知 |
| 线程模型 | 1 连接 1 线程 | 1 线程轮询全部 | 1 线程盯全部 + worker | 提交后干别的 |
| 典型 API | 传统 socket read | fcntl O_NONBLOCK | select/poll/epoll | io_uring / IOCP |
| 代表使用者 | 早期简单服务 | 几乎不用 | Redis / Nginx / MySQL 线程池 | 高性能存储引擎 |
记忆口诀:「阻塞——卡着等;轮询——空转问;多路——等内核喊『谁好了』再去读;异步——内核全干完喊你拿结果。」多路复用和异步的鸿沟就在「读数据这步到底谁做」。
用三个位图(读/写/异常)传 fd 集合,内核遍历找出就绪的。
改用数组 pollfd,去掉 1024 硬上限,但遍历本质没变。
事件驱动:内核维护就绪链表,只返回「真的就绪」的 fd。
struct pollfd,想监听多少都行。所以 select/poll 是「随着连接数 N 增长,开销 O(N)」——连得多但活跃的少时,白遍历一大票空闲连接。
epoll_create(建句柄)→ epoll_ctl(增删改要监听的 fd,只做一次)→ epoll_wait(等就绪,O(1))。结论:select/poll 是 O(N) 全量扫描;epoll 是 O(1) 等通知。这就是为什么高并发服务(Redis、Nginx)清一色 epoll/kqueue。
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大 fd 数 | 1024 硬上限 | 无硬上限 | 无硬上限 |
| 每次调用拷贝 | 全量拷贝进内核 | 全量拷贝 | 仅注册时一次 |
| 找就绪的复杂度 | O(N) 全遍历 | O(N) 全遍历 | O(1) 只取就绪 |
| 触发方式 | 水平触发 | 水平触发 | LT / ET 都支持 |
| 跨平台 | 几乎全平台 | 几乎全平台 | 仅 Linux(BSD 用 kqueue) |
epoll_ctl 增删改,
增删查 O(log n)
epoll_wait 直接取走,
无需遍历红黑树
epoll_wait 时被唤醒,拿到「就绪链表」里那两三个 fd,再去 read。
内核创建一个 epoll 实例,返回句柄(也是一个 fd)。背后初始化红黑树 + 就绪链表。
把要监听的 socket fd 注册进红黑树(设感兴趣事件:可读/可写)。每个连接只注册一次。
线程在此休眠,不占 CPU,直到有 fd 就绪才被唤醒。这是「多路复用」节省 CPU 的核心。
某 socket 来数据,内核把它挂到就绪链表,唤醒 epoll_wait。
epoll_wait 返回只含就绪 fd 的小数组(如 [fd5, fd11]),无需遍历全量。
对就绪 fd 调 read 读数据、执行业务,处理完重新进入 epoll_wait,循环往复。
ae.c`),在 Linux 上底层就是 epoll,把「万级客户端连接」和「命令执行」全串在这一个线程里高效跑完。io-threads),用来并行做「从 socket 读字节 / 往 socket 写字节」这种纯搬运活,读上来 / 写下去之后再交给主线程执行命令。连接监听和事件分发依然靠那个 epoll 多路复用主循环。
每个连上来的客户端,MySQL 从线程缓存里分一个专属线程全程服务它(解析 SQL、执行、回包)。连接少时没问题;但短连接 + 高并发时,线程数随连接数暴涨,上下文切换和内存开销又把前面说的老问题引爆。
MySQL Enterprise / MariaDB 提供 Thread Pool 插件,把「每连接一线程」换成「少量 worker 线程 + 连接分组」。它的网络层就用上了多路复用(poll/epoll):少数线程盯着全部连接,谁就绪就把活丢给空闲 worker 去执行 SQL。
| 对比 | Redis | MySQL(默认 / Thread Pool) |
|---|---|---|
| 连接模型 | 单线程事件循环 + epoll | 默认每连接一线程 / 线程池用多路复用 |
| 命令执行 | 单线程串行(无锁) | worker 多线程并行执行 SQL |
| 多路复用位置 | 贯穿全程(连接 + 执行都在这线程) | 只在「网络监听 / 分发」层,执行交给 worker |
| 为何这样设计 | 内存操作极快,单线程足够且免锁 | SQL 执行重、可能阻塞,需多线程榨干 CPU |
| 一句话 | 「多路复用 + 单线程」典范 | 「多路复用做接入 + 线程池做执行」 |
核心差异一句话:Redis 用多路复用把「接入」和「干活」都收进一个线程(因为活儿轻);MySQL 用多路复用只管「接入分发」,把重的 SQL 执行交给一池 worker 线程(因为活儿重、要并行)。两者都靠多路复用解决「万个空闲连接不占资源」的问题。
错。多路复用是同步的,内核只报就绪,read 还是你自己调;异步 IO 是内核把数据拷完再叫你。
错。epoll 解决「接入海量空闲连接」;若业务是 CPU 密集,还得配 worker 线程并行(见 MySQL)。单线程不等于高性能。
错。命令执行单线程,但 6.0+ 网络读写可多线程;且持久化、惰性删除等另有后台线程。
记忆口诀:「多路复用 = 一个线程看万线,内核喊谁就绪谁才动;select 全扫、epoll 只取就绪;Redis 单线程串全程,MySQL 池里分活给众工。」