IO 多路复用:一个线程为什么可以同时处理大量 HTTP 请求?
我们知道,一个 Web 服务同时面对大量 HTTP 请求时,并不一定需要:
1000 个请求
↓
1000 个线程
对于 IO 密集型服务,更常见的做法是让执行单元在等待网络 IO 时不要一直阻塞,然后利用事件机制处理其他连接。
这就引出了一个非常重要的概念:
IO 多路复用(I/O Multiplexing)。
它是理解 Linux 网络服务器、Nginx、Node.js、Swoole、Hyperf 等高并发网络程序的基础。
1. 先从一个 Socket 开始
一个 HTTP 服务器,本质上需要处理大量网络连接。
例如服务器监听:
0.0.0.0:80
客户端建立 TCP 连接后,操作系统会为连接提供对应的 Socket。
可以简单理解为:
Client A ─── Socket A ──┐
Client B ─── Socket B ──┤
Client C ─── Socket C ──┤
Client D ─── Socket D ──┤
▼
Web Server
假设现在有:
10000 个 TCP 连接
服务器需要解决一个问题:
到底哪个 Socket 当前有数据可以读取?
最简单的思路是:
检查 Socket A
检查 Socket B
检查 Socket C
检查 Socket D
...
如果服务器自己不断轮询:
for each socket:
check()
那么连接数增加以后,效率会越来越低。
更糟糕的是,如果直接对每个 Socket 执行阻塞式 read():
read(Socket A)
↓
一直等
那么 Socket A 没数据时,当前执行线程就可能被阻塞,无法及时处理其他连接。
因此,我们需要一种机制:
把多个 IO 对象交给操作系统,让操作系统告诉我们哪些对象已经准备好进行 IO。
这就是 IO 多路复用。
2. 什么是 IO 多路复用?
IO 多路复用可以先理解为:
一个执行线程同时监视多个 IO 对象,当其中某些 IO 对象具备 IO 条件时,再处理这些对象。
这里的“多路”指多个 IO 通道,“复用”指多个 IO 对象由一个执行线程共同管理。
例如:
┌── Socket A
├── Socket B
Thread ──────┼── Socket C
├── Socket D
└── Socket E
线程并不是:
Thread
↓
一直等待 Socket A
而是:
Thread
↓
等待多个 Socket 的事件
↓
操作系统通知:
Socket B 就绪
Socket D 就绪
↓
处理 B、D
因此,一个线程可以管理大量连接。
注意一个非常重要的概念:
IO 多路复用不是“一个线程同时执行多个 CPU 任务”。
它解决的是:
如何高效等待多个 IO 对象的状态变化
而不是:
如何让一个 CPU 核心同时执行多个计算任务
3. 为什么需要非阻塞 IO?
理解 IO 多路复用之前,需要先理解阻塞和非阻塞。
假设:
Socket A
当前没有数据。
如果使用阻塞读取:
read(Socket A)
可能发生:
Thread
↓
read()
↓
等待数据
↓
阻塞
这期间线程无法继续执行当前调用之后的代码。
如果有:
1000 个 Socket
并且每个 Socket 都使用这种方式:
Thread 1 → Socket A → 阻塞
Thread 2 → Socket B → 阻塞
Thread 3 → Socket C → 阻塞
...
很容易需要大量线程。
而非阻塞 IO 的行为不同。
可以理解为:
read(Socket A)
↓
现在没有数据
↓
立即返回
线程不会一直停在那里等待。
于是:
Thread
↓
检查 Socket A
↓
没数据
↓
继续
↓
检查 Socket B
↓
有数据
↓
读取
但如果让应用程序自己不断检查所有 Socket:
A → B → C → D → E → ...
依然会产生大量无效检查。
因此还需要操作系统提供的事件等待机制。
4. select、poll 和 epoll
Linux 中常见的 IO 多路复用机制包括:
select
poll
epoll
它们解决的是类似的问题:
我有很多 Socket
↓
告诉我哪些 Socket 当前可以进行 IO
但是实现方式和性能特征有所不同。
select
select() 是较早的 IO 多路复用接口。
应用程序向内核提供需要监视的文件描述符集合:
FD 1
FD 2
FD 3
...
FD N
然后:
select()
↓
阻塞等待
↓
某些 FD 就绪
↓
返回
应用程序再检查:
FD 1
FD 2
FD 3
...
找出哪些 FD 就绪。
它存在一些历史上的限制,例如需要传递和检查 FD 集合,而且可监视 FD 数量受到 FD_SETSIZE 等实现限制。
因此在大量连接场景下,select 的扩展性不理想。
poll
poll() 与 select 的用途类似,但接口使用 pollfd 数组表示需要监视的文件描述符。
基本过程:
应用程序
↓
poll([FD1, FD2, FD3...])
↓
内核等待
↓
返回就绪 FD
它避免了 select 的部分 FD 集合接口限制,但应用程序仍然需要把需要监视的 FD 集合交给内核,并在返回后检查这些描述符。
因此,当连接数量非常大时,也存在遍历大量 FD 的成本。
epoll
Linux 提供的 epoll 针对大量文件描述符监视进行了设计。
典型使用过程可以抽象成:
epoll_create
↓
epoll_ctl
↓
注册 Socket
↓
epoll_wait
↓
等待事件
↓
返回就绪事件
例如:
Socket A
Socket B
Socket C
Socket D
...
注册到 epoll:
┌── A
├── B
epoll ───────┼── C
├── D
└── ...
当 Socket C 有事件发生:
Socket C
↓
内核检测到事件
↓
epoll_wait()
↓
返回 C 的事件
应用程序可以直接处理返回的就绪事件。
这与:
每次把 10000 个 Socket 全部检查一遍
的工作方式有明显区别。
5. epoll 到底解决了什么?
经常有人把 epoll 理解成:
“epoll 可以同时处理 10000 个连接。”
这种说法不够准确。
更准确地说:
epoll 提供了一种高效监视大量文件描述符事件的机制,让应用程序可以等待并获取已经就绪的 IO 事件。
例如:
10000 Connections
│
▼
epoll
│
├── Socket 12 就绪
├── Socket 287 就绪
└── Socket 9812 就绪
应用程序只需要重点处理当前返回的就绪事件。
因此:
10000 个连接
并不意味着:
每次都执行 10000 次业务代码
可能某一时刻只有:
3 个 Socket 有数据
那么应用程序主要处理这 3 个事件。
这也是事件驱动服务器能够处理大量连接的重要基础。
Linux epoll(7) 文档将 epoll 描述为一种用于监视多个文件描述符上 IO 事件的机制,尤其适用于监视大量文件描述符的场景。
6. Event Loop:事件循环到底是什么?
有了 epoll 之后,还需要一个程序不断等待和处理事件。
这就是 Event Loop。
可以简单理解为:
while (running) {
events = wait_events();
foreach (events as event) {
handle(event);
}
}
流程就是:
┌──────────────────────┐
│ │
▼ │
等待 IO 事件 │
│ │
▼ │
获取就绪事件 │
│ │
▼ │
执行对应处理逻辑 │
│ │
▼ │
继续等待 ─────────────────────┘
这就是 Event Loop。
例如有:
Socket A
Socket B
Socket C
运行过程可能是:
Event Loop
↓
等待
↓
A 有数据
↓
处理 A
↓
继续等待
↓
C 有数据
↓
处理 C
↓
继续等待
↓
B 有数据
↓
处理 B
所以:
Event Loop 本身不是 IO 多路复用。
更准确的关系是:
IO 多路复用
↓
提供“等待多个 IO 事件”的能力
Event Loop
↓
不断等待事件并分发处理
两者经常组合使用。
7. Reactor:IO 事件如何进入业务代码?
在工程实践中,还经常出现一个概念:
Reactor 模式。
Reactor 可以理解为一种事件驱动的架构模式:
IO 多路复用器
↓
检测事件
↓
事件分发器
↓
Handler
↓
业务逻辑
例如:
Reactor
│
┌──────┴──────┐
▼ ▼
Socket A Socket B
│ │
▼ ▼
Handler A Handler B
整个过程可以表示为:
Socket
↓
Kernel
↓
epoll
↓
Event Loop
↓
Reactor
↓
Handler
↓
Application Code
在不同框架中,具体组件名称和实现方式会不同。
因此不要机械认为:
Reactor = epoll
它们不是一个层次的概念。
更准确地说:
epoll
→ Linux 的 IO 多路复用接口
Event Loop
→ 事件循环机制
Reactor
→ 基于事件驱动组织网络 IO 与 Handler 的架构模式
8. 一个线程处理 10000 个连接到底是怎么回事?
现在把前面的概念全部串起来。
假设服务器存在:
10000 个 TCP 连接
但只有:
1 个 Event Loop 线程
并不意味着这个线程一直执行:
Socket 1
Socket 2
Socket 3
...
Socket 10000
它可能是:
10000 Sockets
│
▼
epoll
│
▼
等待 IO 事件
│
┌───────────┼───────────┐
▼ ▼ ▼
Socket 21 Socket 583 Socket 9201
│ │ │
└───────────┼───────────┘
▼
Event Loop
│
┌───────────┼───────────┐
▼ ▼ ▼
Handler A Handler B Handler C
如果当前只有 3 个连接有数据:
10000 个连接
↓
3 个就绪事件
↓
处理 3 个事件
然后继续等待。
因此,一个线程可以管理远多于线程数量的网络连接。
这就是“一个线程为什么可以处理大量 HTTP 连接”的核心答案。
9. 协程、Event Loop 和 epoll 是什么关系?
对于 Swoole/Hyperf 开发者,这几个概念很容易混在一起。
可以先建立这样的关系:
操作系统
│
epoll
│
IO 多路复用
│
Event Loop
│
协程调度
│
Coroutine A
Coroutine B
Coroutine C
例如:
Coroutine A
↓
MySQL Query
↓
等待
协程可以挂起。
Event Loop 继续处理其他事件:
Coroutine B
↓
Redis
↓
继续执行
或者:
Coroutine C
↓
HTTP Request
↓
等待
当对应 IO 就绪以后:
IO Ready
↓
Event Loop
↓
Coroutine Scheduler
↓
恢复对应 Coroutine
因此可以把它理解为:
epoll
↓
负责发现 IO 事件
Event Loop
↓
负责持续等待和分发事件
Coroutine
↓
负责以更轻量的方式组织业务执行上下文
三者结合起来,就形成了现代高并发 IO 服务常见的运行模型。
10. IO 多路复用并不意味着没有限制
理解 epoll 之后,容易产生一个错误认识:
“既然一个线程可以处理 10000 个连接,那一个线程是不是可以无限处理连接?”
当然不是。
IO 多路复用解决的是:
大量 IO 连接的事件等待和调度
但系统仍然受到大量资源限制:
CPU
内存
Socket
文件描述符
网络带宽
连接数
数据库连接
Redis 连接
下游服务
例如:
HTTP Connections = 10000
并不意味着:
MySQL Connections = 10000
完全可能是:
10000 HTTP Connections
↓
Hyperf
↓
50 MySQL Connections
如果 10000 个请求都需要访问 MySQL:
10000 Requests
↓
50 DB Connections
↓
9500 Requests 等待
此时 epoll 再高效,也不能让 MySQL 同时执行 10000 条 SQL。
因此:
IO 多路复用解决的是 IO 事件管理问题,而不是整个系统的容量问题。
这也是为什么高并发系统最终必须从:
网络
↓
Web Server
↓
应用
↓
连接池
↓
Redis
↓
MySQL
整条链路分析。
总结
IO 多路复用是理解现代高并发网络服务的关键。
最基本的演进过程可以理解为:
阻塞 IO
↓
非阻塞 IO
↓
IO 多路复用
↓
Event Loop
↓
Reactor
↓
协程 / 异步任务
其中:
阻塞 IO
read()
↓
等待
↓
线程被阻塞
非阻塞 IO
read()
↓
没有数据
↓
立即返回
IO 多路复用
多个 Socket
↓
select / poll / epoll
↓
等待事件
↓
返回就绪 Socket
Event Loop
等待事件
↓
处理事件
↓
再次等待
Reactor
IO 事件
↓
事件分发
↓
Handler
↓
业务逻辑
最终可以把一个典型的事件驱动网络服务理解成:
HTTP Requests
│
▼
Socket
│
▼
IO 多路复用
epoll
│
▼
Event Loop
│
▼
Reactor
│
┌────────┴────────┐
▼ ▼
Coroutine A Coroutine B
│ │
▼ ▼
MySQL Redis
│ │
└────────┬────────┘
▼
HTTP Response
所以,“一个线程为什么可以同时处理大量 HTTP 请求”的真正答案并不是:
一个线程同时执行了大量请求。
而是:
一个线程利用 IO 多路复用高效等待大量 Socket 的 IO 事件,在事件就绪时执行相应任务;对于 IO 密集型业务,再结合 Event Loop、异步 IO 或协程,可以让执行资源得到更充分的利用。
还需要特别注意:
epoll 并不会让 CPU 变快,也不会让 MySQL 获得更多并发能力。
它解决的是:
如何高效管理大量 IO 连接。
理解这一点之后,下一篇就可以回答一个更加容易被误解的问题:
“1000 并发”到底是什么意思?
1000 并发、1000 QPS、1000 TPS、1000 个连接到底是不是一回事?
为什么:
1000 QPS + 10ms RT
和:
1000 QPS + 10s RT
对服务器造成的压力完全不同