目录
- 一、先看清对手:I/O 有多慢
- 二、一次 socket 通信里发生了什么
- 三、阻塞 I/O:调用即等待
- 四、非阻塞 I/O:先问再做
- 五、四种 I/O 模型
- 六、就绪通知机制
- 七、水平触发与边沿触发
- 八、常见误区
- 九、自检清单
- 附:延伸阅读
一、先看清对手:I/O 有多慢
讨论并发模型之前,得先承认一个不舒服的事实:CPU 和 I/O 不在一个时间尺度上。
把「一个 CPU 周期」放大成 1 秒,其他操作等比放大后是这样的:
| 系统事件 | 真实延迟 | 等比放大后 |
|---|---|---|
| 一个 CPU 周期 | 0.4 ns | 1 秒 |
| L1 缓存访问 | 0.9 ns | 2 秒 |
| L2 缓存访问 | 2.8 ns | 7 秒 |
| L3 缓存访问 | 28 ns | 1 分钟 |
| 内存访问(DDR) | ~100 ns | 4 分钟 |
| NVMe SSD I/O | ~25 μs | 17 小时 |
| SATA SSD I/O | 50–150 μs | 1.5–4 天 |
| 机械硬盘 I/O | 1–10 ms | 1–9 个月 |
| 旧金山 → 纽约的网络往返 | 65 ms | 5 年 |
(这张表是 Jeff Dean 那套经典「Latency Numbers Every Programmer Should Know」的等比缩放版。)
看清楚最后一行:在网络往返面前,L1 缓存和内存的差别可以忽略不计。 一次跨洲请求的时间里,CPU 足够做完上亿次运算。
于是就有了 I/O 密集型(I/O-bound) 应用的定义:程序大部分时间不是在算,而是在等一个跑得比它慢五个数量级的外部设备。
这里藏着本文所有讨论的起点。既然「等」不可避免,问题就只剩一个:
到底是谁在等?等的时候,CPU 在干什么?
阻塞、非阻塞、同步、异步——这四组词的全部差别,都只是在回答这一个问题。
二、一次 socket 通信里发生了什么
先把场景固定下来:客户端和服务端各持有一个 socket,它是连接的端点。两边都绑定、监听对方的 socket,才能互相说话。
但真正要建立的心智模型是这个——内核缓冲区:
graph LR
A["网卡收到数据"] --> B["内核读缓冲区"]
B --> C["用户进程 recv()"]
C --> D["用户态内存"]
style B fill:#e6f3ff
style D fill:#e6ffe6
关键在于:
- 数据什么时候到达内核缓冲区,取决于网络,不取决于你调不调
recv()。 网卡收到包、内核协议栈解包、放进读缓冲区——这一整套流程在你毫不知情的时候就完成了。 recv()并不「从网络拿数据」。 它做的是「把数据从内核读缓冲区拷贝到用户态内存」。
由此,一次读操作被拆成清清楚楚的两个阶段:
| 阶段 | 做什么 | 耗时特征 |
|---|---|---|
| ① 等待数据就绪 | 内核把数据收进读缓冲区 | 可能非常久(网络慢就干等) |
| ② 拷贝数据 | 内核缓冲区 → 用户态 | 很快(就是内存拷贝) |
下面所有模型的差异,全部落在阶段 ① 和阶段 ② 分别阻不阻塞上。这是理解这一整块知识的钥匙,比任何术语定义都管用。
三、阻塞 I/O:调用即等待
TCP socket 默认就是阻塞模式。写出来是最符合直觉的代码:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 12345))
srv.listen(5)
print("listening on 12345")
while True:
conn, addr = srv.accept() # 阻塞点 1:没人连就干等
print("connected:", addr)
with conn:
while True:
data = conn.recv(1024) # 阻塞点 2:没数据就干等
if not data: # 空字节串 = 对端关闭了连接
break
print(addr, data)
阻塞模式下,recv() 的两个阶段都阻塞:先在阶段 ① 等数据到齐,再在阶段 ② 等拷贝完成。等待期间,线程被挂起、上下文切进内核,这个线程什么也做不了。
于是就有了那个经典症状:服务端被单个客户端占住了。 第二个客户端连接时会卡在 accept() 队列里,直到第一个客户端断开。
为什么会这样
accept() 返回的那条连接,代码是在同一条线程里循环读它的。只要这个客户端还在慢慢发数据,循环就转不出来,accept() 永远没机会被再次调用。
想同时服务多个客户端,朴素解法是一连接一线程:
主线程 accept() → 把 conn 丢给新线程 → 主线程立刻回去 accept()
这方案能跑,但代价明确:
- 每个线程要占内存(栈空间通常 MB 级)
- 线程切换要陷入内核、保存/恢复上下文
- 线程一多,CPU 大量时间花在切换而不是干活上
顺带一提,还有一种容易被忽略的阻塞:send() 在阻塞 socket 上也可能阻塞。当对端读得慢,发送缓冲区被填满,send() 就会停在那儿等缓冲区腾出空间。所以「只读不写就不会阻塞」是错的。
四、非阻塞 I/O:先问再做
把 socket 设为非阻塞,语义立刻变了:send / recv / connect / accept 可以在什么都没做的情况下直接返回。
sock.setblocking(False)
没数据可读时,recv() 不再挂起,而是立刻抛 BlockingIOError——对应 C 里的 EAGAIN / EWOULDBLOCK。
注意这里的非对称性,很多人会漏掉:
- 阶段 ① 不阻塞了——没数据马上告诉你
- 阶段 ② 依然阻塞——一旦数据就绪,拷贝还是同步做完的
忙等是个陷阱
最直接的想法是「循环问」:
while True:
try:
data = conn.recv(1024)
except BlockingIOError:
continue # 没数据?再问一遍
这在绝大多数时间里都在空转烧 CPU:明明没事可做,却把核心占满,还拖慢同一台机器上真正干活的进程。这是最糟的一种写法。
select():把「问」交给内核
问题的本质是:我不知道哪个 socket 就绪了,只能一个个问。
那就让内核一次性告诉我。select() 做的正是这件事:传入一组 socket,阻塞到至少有一个就绪为止,然后返回就绪的那几个。
import select
import socket
def main() -> None:
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setblocking(False)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 12345))
srv.listen(5)
inputs = [srv] # 我们关心的、要等「可读」的 socket 集合
while inputs:
# 阻塞在这里,直到 inputs 里至少一个可读
readable, _, _ = select.select(inputs, [], [])
for s in readable:
if s is srv:
conn, addr = s.accept() # select 说可读了,accept 不会阻塞
conn.setblocking(False)
inputs.append(conn)
else:
data = s.recv(1024)
if data:
print(data)
else:
inputs.remove(s) # 对端关闭
s.close()
现在单线程就能同时处理多个连接:谁先就绪就处理谁,没就绪的一律不浪费 CPU 去问。
这套「非阻塞 socket + 就绪检查」的组合叫 I/O 多路复用(I/O multiplexing)。
它比忙等优雅得多,但要看清它仍然没有解决根本问题:
- 阶段 ① 在
select()里阻塞(不过是同时等一批 socket) - 阶段 ② 在
recv()里阻塞
所以 select() 本质上是「非阻塞 + 同步」——它把等待集中管理了,但等待本身还在,同步拷贝也还在。
五、四种 I/O 模型
现在可以把这些概念摆进同一个坐标系了。「阻塞 / 非阻塞」和「同步 / 异步」是两个正交的维度,不是同义词:
| 同步 | 异步 | |
|---|---|---|
| 阻塞 | 阻塞同步(最简单的 socket 读写) | 阻塞异步(多路复用,可同时等多个资源) |
| 非阻塞 | 非阻塞同步(忙等轮询、readiness 检查) | 非阻塞异步(Promise / Future / 完成通知) |
按 Stevens 在《UNIX 网络编程》里的经典五分类,判断标准是阶段 ①②是否真的都不阻塞:
| 模型 | 阶段 ① 等数据就绪 | 阶段 ② 拷贝数据 | 典型实现 |
|---|---|---|---|
| 阻塞 I/O | 阻塞 | 阻塞 | 默认 socket 读写 |
| 非阻塞 I/O | 不阻塞(轮询) | 阻塞 | setblocking(False) |
| I/O 多路复用 | 阻塞在 select | 阻塞 | select / poll / epoll |
| 信号驱动 I/O | 不阻塞(SIGIO 通知) | 阻塞 | 少见,Windows 无 |
| 异步 I/O | 不阻塞 | 不阻塞 | POSIX AIO、io_uring、Windows IOCP |
注意:只有最后一行才是真正的异步 I/O。
真正的异步 I/O 意味着:你发起请求后立刻拿到一个凭据(Promise / Future),连数据拷贝都由内核完成后才通知你,然后你直接读用户态内存里已经准备好的结果,全程没有任何阻塞点。
graph LR
A["发起读请求"] --> B{"哪种模型?"}
B -- "阻塞" --> C["等就绪 + 等拷贝<br/>全程挂起"]
B -- "非阻塞/多路复用" --> D["等就绪不阻塞<br/>拷贝仍同步"]
B -- "真正的异步" --> E["都不阻塞<br/>完成时回调"]
style C fill:#ffe6e6
style D fill:#fff4e6
style E fill:#e6ffe6
这里有个重要澄清:Python 的 asyncio 并不属于最后一行。
asyncio 是「非阻塞 socket + 事件循环 + 协程」——底层仍然是多路复用,阶段 ② 的拷贝依旧是同步的。它之所以让你感觉像异步,是因为事件循环在你 await 的那一刻把控制权交出去,跑去做别的事了。等待并没有消失,只是被重新分配给了别的任务。
这个区别不是学究式的抠字眼,它解释了很多实际现象:为什么 asyncio 里调用一个同步阻塞函数(比如 time.sleep() 或同步的数据库驱动)会卡死整个事件循环——因为那段时间里,唯一的线程被占住了,没有任何人能接管。
六、就绪通知机制
回到多路复用那条线。既然要让内核告诉我们「谁就绪了」,那这个「告诉」怎么实现,就决定了性能上限。
演进脉络
| 机制 | 就绪集合怎么拿 | 复杂度 | fd 上限 |
|---|---|---|---|
select | 每次调用重新传入整个 fd 集合,内核遍历 | O(n) | FD_SETSIZE(通常 1024) |
poll | 传 fd 数组,内核遍历,无上限 | O(n) | 无硬上限 |
epoll(Linux) | epoll_ctl 一次性注册,内核维护就绪链表,直接返回 | O(1) 取就绪 | 无硬上限 |
kqueue(BSD/macOS) | 同类思路,BSD 阵营的实现 | O(1) 取就绪 | 无硬上限 |
epoll 的两个关键改进:
- 不需要每次调用都重传整个 fd 集合。fd 通过
epoll_ctl注册一次,内核用红黑树维护;就绪的 fd 由内核挂进一条就绪链表,epoll_wait直接取。 - 不需要遍历所有 fd 找就绪的。连接数从 100 涨到 10000,
epoll_wait的开销基本不变。
这也是 C10K 问题(单机同时处理一万个连接)从「换更贵的机器」变成「换更好的模型」的原因。
不过别把结论下得太糙:连接数少的时候,select 完全够用,甚至可能比 epoll 更快——因为 epoll_ctl 注册和 epoll_wait 各有系统调用开销,而 select 一次调用搞定。epoll 的优势是连接数大且活跃比例低时才凸显出来的。
七、水平触发与边沿触发
epoll 有两种通知语义,这是实践中最容易踩坑的地方。
水平触发(LT, Level Triggered)— 默认
只要读缓冲区里还有数据,每次 epoll_wait 都会持续报告这个 fd 就绪。
语义是「状态」:现在可读,就一直告诉你可读。
- 你没读完,下次还会被通知 → 安全,不容易漏数据
select/poll只有这一种行为- 代价:如果某个 fd 一直不处理,它会一直出现在就绪列表里,形成忙等
边沿触发(ET, Edge Triggered)— 需显式开启
只在状态发生变化的那一刻通知一次。
语义是「变化」:从不可读变成可读,通知一次;之后不管缓冲区里还剩多少,都不再通知。
import select
ep = select.epoll()
ep.register(srv.fileno(), select.EPOLLIN | select.EPOLLET) # EPOLLET = 边沿触发
ET 的铁律:收到通知后,必须循环读到 EAGAIN 为止。
while True:
try:
data = conn.recv(4096)
except BlockingIOError:
break # EAGAIN —— 真的读干净了,这才是正确退出条件
if not data:
conn.close() # 空字节串 = 对端关闭
break
handle(data)
为什么必须这样?因为 ET 只为「变化」通知一次。假设内核缓冲区里有 8KB 数据,你只 recv(1024) 读了 1KB 就去干别的了:
- 剩下 7KB 还在缓冲区里
- 但没有发生「新的变化」,所以不会再有事件
- 除非对端又发了新数据(产生新边沿),否则这 7KB 会永远躺在缓冲区里没人管
这是个非常隐蔽的 bug:本地测试时客户端发得快,每次 recv 时数据量恰好小,看不出问题;上了生产环境数据量大、TCP 分段一乱,就开始莫名其妙丢消息或卡住。
ET 的收益:减少了 epoll_wait 的返回次数(不必反复返回同一个就绪 fd),在超高并发下能省下可观的系统调用。代价:必须配合非阻塞 fd,且必须自己保证读干净——写错了就是线上事故。
建议:除非有明确的性能诉求并做了压测,否则默认用 LT。这些细节通常被框架封装好了,但出问题时你得知道去哪儿看。
八、常见误区
❌ 「非阻塞一定比阻塞快」 非阻塞不提升单次 I/O 的吞吐。它换来的是并发能力——让一个线程能照看多个连接。单连接场景下,非阻塞那套循环反而更慢更复杂。
❌ 「异步 = 多线程」 两者解决的是同一个问题(等待时别闲着),但路子相反。多线程靠操作系统抢占式调度,多个线程真并行;异步靠单线程协作式调度,任务主动让出。下一页的系列笔记会专门讲这个区别。
❌ 「阻塞就是坏的」 阻塞的代码最好读、最好调试。绝大多数业务系统里,一请求一线程(或线程池)完全够用,可维护性远胜于过早引入异步。只有当并发连接数或 I/O 等待成为实测瓶颈时,异步的复杂度才值得付。
❌ 「epoll 比 select 快是因为系统调用少」
更准确的说法是它不需要每次重传整个 fd 集合、也不需要遍历全部 fd。在连接少的时候它的优势并不明显。
❌ 「Python 的 asyncio 是异步 I/O」 如上文所述,它是多路复用 + 事件循环 + 协程语法糖,不是 POSIX AIO 那一层。理解这点,才能明白为什么在里面混用任何同步阻塞调用都是灾难。
九、自检清单
- 能说出一次读操作的两个阶段,以及各模型分别在哪个阶段阻塞
- 知道
recv()是从内核缓冲区拷贝,而不是从网络取数据 - 知道
send()在阻塞 socket 上也可能阻塞 - 说得出
EAGAIN/EWOULDBLOCK的含义 - 能解释为什么
select属于「非阻塞 + 同步」,而非异步 - 知道
epoll相比select的两个关键改进 - 记得 ET 模式下必须循环读到
EAGAIN,并能说出不这么做会怎样 - 能回答「
asyncio为什么会被一个time.sleep()卡死」
附:延伸阅读
本篇整理自
- Asynchronous Programming. Blocking I/O and non-blocking I/O —— luminousmen(Kirill Bobrov),2020-04-26
这是作者「Exploring Asynchronous Programming」5 篇系列的第 1 篇,后续依次是:Threads and processes → Cooperative multitasking → Await the Future → Python 3.5+。本文在原作框架上做了补充与勘误,代码示例为便于说明重新编写。
相关资料
| 资源 | 说明 |
|---|---|
| 《UNIX 网络编程》(UNP)第 1 卷第 6 章 | 五种 I/O 模型的权威出处,本章讲得比我清楚 |
| The C10K Problem | 单机万级并发的经典问题描述史 |
| man 7 epoll | LT/ET 语义的官方定义,含触发条件示例 |
| man 2 select / man 2 poll | FD_SETSIZE 限制与 EINTR 处理 |
| Latency Numbers Every Programmer Should Know | 第一节那张表的原始出处 |
| io_uring | Linux 上真正的异步 I/O,现代演进方向 |
关联笔记
- 系统扩展:从 0 到 1000 万用户 —— 什么时候真的需要为 I/O 模型操心
最后更新:2026-09-28