这是把 asyncio、Netty、Nginx、Redis 单线程高性能原理全部打通的一篇。
一、从一个问题开始:服务器怎么处理 10 万连接?
# 最朴素的服务器
server = socket.socket()
server.bind(("0.0.0.0", 8080))
server.listen()
while True:
conn, addr = server.accept() # ← 阻塞 1:等连接
data = conn.recv(1024) # ← 阻塞 2:等数据
conn.send(b"ok")
conn.close()
问题:accept 和 recv 都是阻塞的,第 1 个连接没断开,第 2 个连接连 accept 都进不来。
第一反应:每个连接开一个线程。
while True:
conn, _ = server.accept()
threading.Thread(target=handle, args=(conn,)).start()
问题升级:1 万连接 = 1 万线程。
- 每个线程默认栈 1-8MB → 内存爆炸
- 上下文切换成本剧增 → CPU 大部分时间在切换而非工作
- C10K 问题(10000 connections)就是被这个范式卡住的
真正的解法:让一个线程同时盯住成千上万个 socket,谁就绪就处理谁。这就是 I/O 多路复用。
二、五种 I/O 模型(Unix 网络编程经典分类)
以 recvfrom 为例,把"读数据"拆成两个阶段:
- 等数据就绪(数据从网卡到内核缓冲区)
- 拷贝数据(从内核缓冲区拷贝到用户空间)
2.1 阻塞 I/O(BIO)
应用:recvfrom() ─┐
│ 阻塞等待数据 + 拷贝
内核:←───────────┘
最简单,但一个线程只能盯一个 fd。
2.2 非阻塞 I/O(NIO)
sock.setblocking(False)
while True:
try:
data = sock.recv(1024)
break
except BlockingIOError:
pass # 没数据就继续轮询
调用立即返回,但忙轮询浪费 CPU。
2.3 I/O 多路复用(核心主角)
应用:select/poll/epoll() ─┐ 阻塞,但同时盯多个 fd
│
内核:←─────────────────────┘ 任一就绪就返回
应用:recvfrom() // 此时数据已就绪,拷贝即可
一次系统调用,盯多个 fd。代价是要两次系统调用(select + recvfrom),但能管理海量连接。
2.4 信号驱动 I/O
注册 SIGIO 信号,数据就绪时内核发信号通知。实际很少用,因为信号处理函数能做的事极有限。
2.5 异步 I/O(AIO)
应用:aio_read() ──→ 立即返回
内核:(等数据 + 拷贝完成)── 通知应用
两个阶段都不阻塞。Linux AIO 历史上一直不完善,io_uring(5.1+)才真正可用。Windows IOCP 是更早的优秀实现。
5 种模型对比
| 模型 | 阶段 1 (等数据) | 阶段 2 (拷贝) |
|---|---|---|
| BIO | 阻塞 | 阻塞 |
| NIO | 非阻塞轮询 | 阻塞 |
| 多路复用 | 阻塞在 select | 阻塞 |
| 信号驱动 | 不阻塞 | 阻塞 |
| AIO | 不阻塞 | 不阻塞 |
关键洞察:多路复用本质仍是同步 I/O(阶段 2 阻塞),只是把"等"这件事批量化了。
三、select / poll / epoll 演进史
3.1 select(1983 年,POSIX 标配)
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock, &readfds);
select(maxfd + 1, &readfds, NULL, NULL, &timeout);
痛点:
- fd 数量上限 1024(FD_SETSIZE 写死)
- 每次调用都要把 fd_set 从用户态拷贝到内核态
- 内核线性遍历所有 fd 检查就绪
- 返回后用户也要再遍历一遍找出就绪 fd
时间复杂度 O(N),N 是监听的 fd 数。
3.2 poll(1986)
只解决了没有 1024 上限的问题(用链表代替数组)。其他痛点全部保留,复杂度仍是 O(N)。
3.3 epoll(Linux 2.6,2002)⭐ 现代高性能服务的根基
int epfd = epoll_create1(0); // 创建 epoll 实例
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &event); // 注册 fd(一次)
int n = epoll_wait(epfd, events, MAX, timeout); // 等待就绪
for (int i = 0; i < n; i++) { // 直接拿到就绪 fd 数组
handle(events[i].data.fd);
}
三大革命性改进:
① 红黑树 + 就绪链表
内核维护两个数据结构:
- 红黑树:存所有注册的 fd,O(log N) 增删改
- 就绪链表(双向链表):存就绪的 fd,O(1) 取出
[红黑树:所有监听的 fd]
↓ 数据到达,回调
[就绪链表:等被处理]
↓ epoll_wait 取出
用户拿到就绪 fd 数组
② 回调机制(事件驱动)
注册 fd 时把它挂到对应的设备等待队列上。网卡数据到达 → 触发中断 → 内核回调 → 把 fd 加入就绪链表。不再轮询遍历。
③ mmap 共享内存
epoll_wait 通过 mmap 让内核和用户共享就绪事件数组,避免拷贝。
3.4 三者对比
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 1024 | 无 | 无(系统级) |
| 时间复杂度 | O(N) | O(N) | O(1) 取就绪 + O(log N) 注册 |
| 拷贝开销 | 每次调用都拷贝 | 每次都拷贝 | 注册一次,wait 时不拷贝 |
| 触发方式 | 水平触发 | 水平触发 | 水平 + 边缘 |
| 跨平台 | ✅ | ✅ Unix-like | ❌ 仅 Linux |
3.5 各平台对应实现
| 平台 | 高性能多路复用 |
|---|---|
| Linux | epoll |
| BSD / macOS | kqueue(设计更优雅,覆盖更多事件类型) |
| Windows | IOCP(实际是 AIO 而非多路复用) |
| Solaris | event ports |
跨平台库(如 libevent、libuv、Java NIO)会自动选择对应实现。
四、水平触发(LT) vs 边缘触发(ET)
4.1 水平触发(Level-Triggered,默认)
只要 fd 上有数据可读,每次 epoll_wait 都通知。
# LT 下安全的写法
data = sock.recv(1024) # 没读完没关系,下次 epoll_wait 还会通知
4.2 边缘触发(Edge-Triggered)
只在 fd 状态从无到有时通知一次,没读完就再也不通知了。
# ET 下必须循环读到 EAGAIN
while True:
try:
data = sock.recv(1024)
if not data: break
except BlockingIOError:
break # 数据读完了
4.3 怎么选?
| 维度 | LT | ET |
|---|---|---|
| 编程难度 | 低 | 高(必须非阻塞 + 循环读尽) |
| 性能 | 触发次数多 | 触发次数少 |
| 应用 | redis、libevent 默认 | nginx 默认 |
经验:除非你在写性能极致的中间件,用 LT 即可。
五、Reactor 模式:把多路复用包装成可用的编程模型
光有 epoll 还不够,得有一套把"事件 → 处理函数"组织起来的范式。这就是 Reactor。
5.1 核心组件
┌──────────────────────────────────────────┐
│ Event Loop(事件循环) │
│ while True: │
│ events = epoll.wait() │
│ for ev in events: │
│ dispatch(ev) → handler │
└──────────────────────────────────────────┘
│ │
[Acceptor 处理新连接] [Handler 处理 I/O]
- Reactor(反应器):核心循环,负责 epoll_wait 并分发事件
- Acceptor:处理 ACCEPT 事件(新连接进来)
- Handler:处理 READ/WRITE 事件
5.2 单 Reactor 单线程(Redis 模式)
[ Reactor + Acceptor + 所有 Handler ] ← 全在一个线程
↑
epoll_wait
- 优点:无锁、简单
- 缺点:单核到顶;任一 Handler 阻塞或耗时,整个服务卡住
- 代表:Redis 6.0 之前
Redis 为什么能单线程跑到 10w QPS?
- 内存操作 → 单次命令 < 1μs
- 多路复用 → 一个线程盯所有连接
- 无锁 → 无并发开销
- 高效数据结构(跳表、压缩列表等)
5.3 单 Reactor 多线程
[ Reactor + Acceptor ]
│
├──→ 派发到 [Worker 线程池] 处理业务
│
epoll 仍单线程
- I/O 还是单线程,CPU 密集型业务在线程池
- 缺点:单 Reactor 在高并发下成瓶颈
5.4 主从 Reactor(Netty / Nginx 模式)⭐
[Main Reactor] ← 只 accept 新连接
│
└─→ 分配给某个 Sub Reactor
│
[Sub Reactor 1] ← I/O 读写
[Sub Reactor 2] ← I/O 读写
[Sub Reactor N] ← I/O 读写
│
└─→ Worker 线程池(可选,处理 CPU 密集)
- Main Reactor:1 个线程,专门 accept
- Sub Reactor:通常 = CPU 核数,每个有独立 epoll
- 完美利用多核,是现代高性能服务的事实标准
- 代表:Netty、Nginx(Nginx 是多进程版本,每个 worker 是一个 Reactor)
5.5 Proactor 模式(基于 AIO)
Reactor 是就绪通知(你来读),Proactor 是完成通知(我读完给你)。
你发起读 → 内核完成读取 → 通知你「数据已经在 buffer 里了」
- 代表:Windows IOCP、Linux io_uring
- 编程模型更简单,但 Linux AIO 历史上不成熟,io_uring 才让 Proactor 在 Linux 真正可用
六、Python asyncio:用户态 Reactor 实现
6.1 asyncio 的本质
import asyncio
import selectors
# asyncio 内部就是一个 Reactor:
# - selectors 模块封装 epoll/kqueue/select
# - 事件循环 = while True: events = selector.select(); dispatch(events)
# - 协程 = 用户态轻量任务,await 时让出控制权
# 简化的事件循环(伪代码)
def run_forever():
while True:
events = self._selector.select(timeout) # epoll_wait
for key, mask in events:
callback = key.data
callback() # 唤醒对应协程
run_ready_callbacks() # 跑就绪的协程
6.2 协程让出 CPU 的瞬间
async def fetch():
data = await reader.read(1024)
# ↑
# 这一刻发生了什么?
# 1. socket 注册到 epoll,关注 EPOLLIN
# 2. 当前协程挂起,加入"等待 fd 就绪"的字典
# 3. 控制权回到事件循环
# 4. epoll_wait 等待
# 5. fd 就绪 → 找到对应协程 → 恢复执行
6.3 用 selectors 手撸一个 Reactor
import selectors
import socket
sel = selectors.DefaultSelector() # Linux 上自动选 epoll
def accept(sock, mask):
conn, addr = sock.accept()
print(f"accepted {addr}")
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, read)
def read(conn, mask):
data = conn.recv(1024)
if data:
conn.send(data) # echo
else:
sel.unregister(conn)
conn.close()
server = socket.socket()
server.bind(("0.0.0.0", 8080))
server.listen()
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, accept)
while True:
events = sel.select()
for key, mask in events:
callback = key.data # accept 或 read
callback(key.fileobj, mask)
70 行不到的 echo 服务器,能扛住上万连接。这就是 asyncio 的内核思想。
七、性能数字与实测
7.1 单线程能扛多少连接?
| 模式 | 实测连接数(普通服务器) |
|---|---|
| 一个连接一个线程 | ~5000(线程切换瓶颈) |
| 一个连接一个进程 | ~1000(更重) |
| epoll 单线程 | 100,000+(C10K 问题解决) |
| epoll 多核 + Reactor | 百万级(C10M 挑战) |
7.2 同步 vs 异步 echo 服务器
| 实现 | QPS(4 核) |
|---|---|
| BIO + 线程池(Java) | ~10K |
| Java NIO + Netty | ~80K |
| Python asyncio | ~30K |
| Nginx | ~100K+ |
| C + epoll 裸写 | ~200K+ |
数字仅供参考,实际取决于业务复杂度、I/O 比例、网络条件。
八、实战中的坑
8.1 阻塞操作毁掉事件循环
async def bad():
time.sleep(5) # ❌ 整个事件循环停 5 秒
requests.get(url) # ❌ 同步阻塞
cpu_heavy_compute() # ❌ CPU 密集
async def good():
await asyncio.sleep(5)
await asyncio.to_thread(requests.get, url) # 丢线程池
await loop.run_in_executor(None, cpu_heavy) # 同上
原则:协程里绝不能调用任何阻塞 I/O 或长时间 CPU 操作。
8.2 文件 I/O 没有真异步(Linux)
Linux epoll 不支持普通文件(只支持 socket、pipe、tty 等)。Python aiofiles 实际是用线程池模拟。真正的文件异步要靠 io_uring。
8.3 ET 模式忘记循环读
# ❌ ET 模式下数据读不完
data = sock.recv(1024)
# ✅ 必须读到 EAGAIN
while True:
try:
chunk = sock.recv(1024)
if not chunk: break
buffer += chunk
except BlockingIOError:
break
8.4 惊群问题(Thundering Herd)
多个进程同时 epoll_wait 同一个监听 socket,新连接到达时所有进程都被唤醒,但只有一个能 accept 成功。
解决:
- Linux 4.5+ 的
EPOLLEXCLUSIVE标志 - Linux 3.9+ 的
SO_REUSEPORT:每个进程独立监听端口,内核分流 - Nginx 的 accept_mutex(旧方案)
8.5 epoll 的 fd 泄漏
注销 fd 但没 epoll_ctl(EPOLL_CTL_DEL, ...) → 红黑树堆积。close(fd) 会自动从 epoll 移除(Linux 2.6.9+),但多个进程共享 fd 时不会,要手动 DEL。
九、io_uring:下一代 Linux 异步 I/O(2019+)
9.1 为什么需要它?
epoll 的局限:
- 不支持普通文件异步
- 仍有系统调用开销(每次 epoll_wait + recv)
- 不是真正的 AIO(阶段 2 仍阻塞)
9.2 核心设计:两个共享内存环形队列
[用户态] [内核态]
↓ 提交请求 ↑
SQ (Submission Queue) ───────────────→ 内核处理
↑ 完成结果 ↓
CQ (Completion Queue) ←─────────────── 内核填回
- 用户态把 I/O 请求写进 SQ
- 内核处理完写进 CQ
- 零拷贝、零系统调用(极端模式下)
- 支持几乎所有 I/O:文件、网络、定时器、
fsync、accept、connect
9.3 性能
- 比 epoll 提升 20-50% 吞吐
- 极端场景(如数据库)能提升 3-5 倍
- ScyllaDB、Cassandra、Postgres 17 都已用上
9.4 现状
- Linux 5.1+ 起逐步成熟
- Python 有
io_uring第三方绑定,未来可能进官方 - Rust 的
tokio-uring、monoio已成熟 - 未来 5-10 年会逐步取代 epoll
十、把所有东西串起来:一张图
┌───────────────────────────────────────────────────────────┐
│ 应用层编程模型 │
│ asyncio / Netty / Tokio / libevent / Nginx │
└──────────────────────────┬────────────────────────────────┘
│ Reactor 模式封装
┌──────────────────────────▼────────────────────────────────┐
│ 跨平台多路复用抽象 │
│ selectors (Python) / NIO (Java) / mio (Rust) │
└──────┬─────────────┬─────────────┬─────────────────────┬──┘
│ │ │ │
┌───▼───┐ ┌────▼───┐ ┌────▼───┐ ┌──────▼──────┐
│ epoll │ │ kqueue │ │ IOCP │ │ io_uring │
│ Linux │ │ BSD │ │Windows │ │ Linux 5.1+ │
└───┬───┘ └────────┘ └────────┘ └─────────────┘
│
┌───▼──────────────────────┐
│ 红黑树 + 就绪链表 + mmap │
└──────────────────────────┘
十一、面试高频题速记
- Q:select 和 epoll 的区别? A:select 有 1024 上限、O(N) 遍历、每次拷贝 fd 集;epoll 用红黑树 + 就绪链表 + mmap,注册一次、O(1) 取就绪、回调驱动
- Q:epoll 的两种触发模式? A:LT 只要有数据就持续通知;ET 只在状态变化时通知一次,必须循环读到 EAGAIN
- Q:Redis 单线程为什么这么快? A:内存操作 + epoll 多路复用 + 无锁 + 高效数据结构
- Q:Reactor 和 Proactor 的区别? A:Reactor 是就绪通知(用户来读),Proactor 是完成通知(内核读完后给用户)
- Q:Netty 的线程模型? A:主从 Reactor 多线程:BossGroup 负责 accept,WorkerGroup 负责 I/O
- Q:asyncio 底层是什么? A:用户态事件循环 + selectors(封装 epoll/kqueue),协程在 await 时让出,I/O 就绪后被唤醒
- Q:为什么协程比线程效率高? A:用户态调度无需陷入内核、栈极小(KB 级 vs MB 级)、切换不走 CPU 上下文保存
- Q:io_uring 解决了 epoll 什么问题? A:支持文件异步、减少系统调用、真正的 AIO(阶段 2 也不阻塞)
- Q:什么是惊群?怎么解决? A:多进程同时 epoll_wait 同一 socket,新连接来时全被唤醒;用 SO_REUSEPORT 或 EPOLLEXCLUSIVE 解决
- Q:BIO/NIO/AIO 的区别? A:阶段 1 等数据 + 阶段 2 拷贝是否阻塞,BIO 都阻塞,NIO 阶段 1 非阻塞,AIO 都不阻塞