异步编程(一):阻塞 I/O 与非阻塞 I/O

目录


一、先看清对手:I/O 有多慢

讨论并发模型之前,得先承认一个不舒服的事实:CPU 和 I/O 不在一个时间尺度上。

把「一个 CPU 周期」放大成 1 秒,其他操作等比放大后是这样的:

系统事件真实延迟等比放大后
一个 CPU 周期0.4 ns1 秒
L1 缓存访问0.9 ns2 秒
L2 缓存访问2.8 ns7 秒
L3 缓存访问28 ns1 分钟
内存访问(DDR)~100 ns4 分钟
NVMe SSD I/O~25 μs17 小时
SATA SSD I/O50–150 μs1.5–4 天
机械硬盘 I/O1–10 ms1–9 个月
旧金山 → 纽约的网络往返65 ms5 年

(这张表是 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

关键在于:

  1. 数据什么时候到达内核缓冲区,取决于网络,不取决于你调不调 recv()。 网卡收到包、内核协议栈解包、放进读缓冲区——这一整套流程在你毫不知情的时候就完成了。
  2. 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 的两个关键改进:

  1. 不需要每次调用都重传整个 fd 集合。fd 通过 epoll_ctl 注册一次,内核用红黑树维护;就绪的 fd 由内核挂进一条就绪链表,epoll_wait 直接取。
  2. 不需要遍历所有 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() 卡死」

附:延伸阅读

本篇整理自

这是作者「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 epollLT/ET 语义的官方定义,含触发条件示例
man 2 select / man 2 pollFD_SETSIZE 限制与 EINTR 处理
Latency Numbers Every Programmer Should Know第一节那张表的原始出处
io_uringLinux 上真正的异步 I/O,现代演进方向

关联笔记


最后更新:2026-09-28