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 最超群;

四种模型分明白,数据没到各有门。

相关链接