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

对服务器造成的压力完全不同