IO 模型:阻塞 / 非阻塞 / 多路复用 / 异步
一句话:程序要读数据,数据还没到的时候,程序该怎么办?四种 IO 模型就是四种”等数据”的策略。
先搞清楚”谁在等”:发起 IO 调用的是线程(
sock.recv()在哪个线程里调,就是那个线程在等)。早期注记习惯写”进程”是因为一个进程只有一个线程的时代。以下用线程指代”发起 IO 调用的执行实体”。
一、四种 IO 模型一览
graph TD A[程序发起 IO 请求] --> B{等不等数据?} B -->|等,一直等到有数据| C[阻塞IO<br/>线程睡觉,数据到了叫醒] B -->|不等,立刻返回| D[非阻塞IO<br/>没数据就返回错误,下次再问] B -->|等,但一次等一堆| E[IO多路复用<br/>交给select/epoll,有数据了通知我] B -->|不等,内核全包| F[异步IO<br/>提交请求给内核,内核读好复制好再通知]
| 模型 | 等的时候在干嘛 | 谁等的 | 效率 |
|---|---|---|---|
| 阻塞 IO | 线程睡觉,不占 CPU | 线程自己 | 低 |
| 非阻塞 IO | 不断问”好了没”,占 CPU | 线程自己 | 中(浪费 CPU) |
| IO 多路复用 | 一个线程等一堆 IO,哪个好了处理哪个 | 专门的线程 | 高 |
| 异步 IO | 说一声就不管了,内核做完通知 | 内核 | 最高 |
2. 非阻塞 IO——“不断问”但没人这么用
教科书定义
sock.setblocking(False)
while True:
data = sock.recv(1024)
if data:
process(data)
break
else:
# 没数据,过一会儿再问
time.sleep(0.1)
recv() 非阻塞模式:
→ 没数据?立刻返回 EAGAIN
→ 你的程序继续跑,过一会儿再问
→ 有数据了?recv() 返回数据
生活类比
你在奶茶店点了一杯奶茶:
阻塞 IO = 站在柜台前干等
非阻塞 = 坐在座位上,每 10 秒去问一次"好了没?"
大部分时候回答"还没"
⚠️ 核心理解:非阻塞 IO 不是独立模型
教科书说非阻塞 IO 的卖点:
"一个线程可以轮询多个 fd,不用等"
但实际生产没人这么干——自己轮询是在浪费 CPU。
非阻塞的真正角色是给 IO 多路复用(epoll)当**工具人**:
epoll_wait 告诉你"fd3 有数据了"
你去 read(fd3) —— 这部分必须是**非阻塞**的
为什么?因为你以为有 10KB,实际只来了 4KB
阻塞 read → 读完 4KB 再读 → 卡死
非阻塞 read → 读完了返回 EAGAIN → 你知道了,等下一轮 epoll 通知
一句话:非阻塞 IO 自己出不了头。
但多路复用的 LT 和 ET 全靠它吃饭。
4. 异步 IO(Asynchronous IO)——“说一声,内核帮你全搞定” ⭐最高级
和前面三种的本质区别
前面的三种模型 —— 同步 IO:
阻塞 IO: 线程等
非阻塞 IO: 线程问
IO 多路复用: 线程让内核盯着,然后线程等通知
共同点:数据从内核复制到用户 buf 这一步,还是线程自己做的。
异步 IO:
你告诉内核:"帮我读这个文件,数据放到 buf 里,读完了叫我"
然后你什么都不用管——内核帮你读、帮你复制到 buf
全部搞定了才通知你
sequenceDiagram participant P as 线程 participant K as 内核 participant D as 硬盘 Note over P,K: 同步 IO P->>K: read() K->>D: 读数据 D-->>K: 数据来了 K-->>P: 复制到 buf,返回 P->>P: 继续处理 Note over P,K: 异步 IO P->>K: aio_read(buf) P->>P: 继续干别的事,完全不阻塞 K->>D: 读数据 D-->>K: 数据来了 K->>K: 复制到 buf(内核帮你做) K-->>P: 通知:读完了,数据在 buf 里 P->>P: 直接处理 buf 里的数据
生活类比
同步 IO(阻塞/非阻塞/多路复用):
你叫外卖(发 IO 请求)
外卖到了你去门口拿(自己从内核复制数据到 buf)
外卖在门口,你不去拿它就在门口放着
异步 IO:
你叫外卖(发 IO 请求)
外卖到了,外卖小哥直接帮你摆桌上(内核帮你复制到 buf)
然后叫你:"饭好了,直接吃!"
你从桌上直接开吃(直接读 buf)
异步 IO 的实现
Linux:aio_read() / io_uring(最新、最强)
Windows:IOCP
macOS:Kqueue 可以做到类似效果
优点 vs 缺点
| 优点 | 缺点 |
|---|---|
| 效率最高——完全不阻塞线程 | 编程复杂 |
| 一个线程可以发起大量 IO | 调试困难 |
| 内核帮你做了复制的工作 | 有些操作系统支持不好 |
二、你在实际项目里用的是哪个
你在 FastAPI 里写的 async/await:
async def read_file():
data = await aio_read("test.txt")
# 看起来像是异步 IO
# 但实际上是 IO 多路复用
Python asyncio 是第几种?
Python asyncio 底层用的是 IO 多路复用(epoll/select)
不是异步 IO。
为什么?
Python 的 aio_read() 实际上:
① 把 fd 交给事件循环(epoll 监控)
② 事件循环阻塞在 epoll 上
③ epoll 说"有数据了"
④ 事件循环把数据从内核复制到 buf
⑤ 恢复协程执行
数据复制这一步还是事件循环做的——所以是"同步 IO 多路复用"。
真正的异步 IO(io_uring):
① 提交 IO 请求到内核队列
② 你的协程继续执行
③ 内核自己读数据、自己复制到 buf
④ 内核把完成的请求放到完成队列
⑤ 你的协程从队列里拿结果
三、记忆口诀
阻塞 IO 最简单——一个线程一个 IO,并发高了完蛋
非阻塞 IO 来回问——浪费 CPU,写起来麻烦
多路复用 epoll——一个线程监控全部,高并发首选
异步 IO 最牛逼——交给内核全搞定,Linux 用 io_uring
总结:四种 IO 模型的核心区别在”数据没到的时候,线程干嘛”和”数据复制这一步谁做”。阻塞最简单、多路复用最常用(epoll 就是干这个的)、异步 IO 最高级。
速记卡(面试闪卡)
Q1:一句话讲清「IO 模型:阻塞 / 非阻塞 / 多路复用 / 异步」到底是什么?
A:四种 IO 模型是数据没到时线程怎么等、复制这步谁来做四种策略。
Q2:阻塞与非阻塞——等还是问 —— 怎么理解?
A:阻塞 IO 时线程睡觉等数据,不占 CPU 但并发差;非阻塞立刻返回错误、靠轮询反复问,浪费 CPU。像奶茶店:阻塞是站柜台干等,非阻塞是每 10 秒去问一次”好了没”(blocking / non-blocking)。
Q3:IO 多路复用——一个线程盯一片 —— 怎么理解?
A:多路复用把一堆 fd 交给 select/epoll,哪个有数据了再通知处理,一个线程管全部高并发。非阻塞 IO 其实是它的工具人——read 必须非阻塞才不会卡死。像一个人盯着十台微波炉,哪台”叮”了取哪台(IO multiplexing / epoll)。
Q4:异步 IO——内核全包 —— 怎么理解?
A:异步 IO 提交请求后线程完全不管,内核读完还帮你把数据复制到 buf 才通知。同步 IO 三种都要自己复制,异步最彻底。像叫外卖:同步自己下楼拿,异步小哥直接摆桌上叫你吃(asynchronous IO / io_uring)。
Q5:实战里用的是哪种 —— 怎么理解?
A:Python asyncio 底层是 epoll 多路复用,不是真异步——数据复制还是事件循环做。真正的异步是 io_uring:内核自己读、自己复制、自己通知。别被 async/await 的名字骗了(epoll vs io_uring)。
Q6:核心速记主线有哪些?
-
阻塞:线程睡觉等,简单但并发差
-
非阻塞:立即返回轮询,浪费 CPU
-
多路复用:epoll 一个线程管全部,最常用
-
异步:内核全包,io_uring 最高级
口诀
A:阻塞睡觉不占芯,非阻塞问费精神;
epoll 一人盯全场,多路复用最称心;
异步内核全包办,io_uring 最超群;
四种模型分明白,数据没到各有门。