先看一组我在自己机器上跑出来的数:
import asyncio, time
async def one():
await asyncio.sleep(0.02)
# 1000 个一起跑
await asyncio.gather(*(one() for _ in range(1000))) # 0.049 s
# 1000 个排着队跑
for _ in range(1000):
await one() # 30.953 s
差了 626 倍。整个过程没开一个线程,没起一个进程,就是一个线程在那儿转。
很多人把这件事理解成「asyncio 比线程快」,其实不对。快的是等待被重叠了,不是代码跑得更快。要弄清这一点,得从 async def 到底造出了什么开始拆。
一、async def 定义的是协程函数,不是普通函数
import inspect
async def fetch():
return 42
print(inspect.iscoroutinefunction(fetch)) # True
print(type(fetch)) # <class 'function'>
函数还是那个函数对象,只是被打了个标记:CO_COROUTINE。这个标记决定了调用它的时候,解释器不会立刻执行函数体。
二、调用它,函数体一行都不会跑
这是新手最容易懵的地方:
c = fetch()
print(type(c)) # <class 'coroutine'>
print(inspect.iscoroutine(c)) # True
fetch() 返回的是一个协程对象,一串还没启动的字节码。此时函数体里哪怕写着 print("hello"),也不会输出。
直接这么写,Python 会给你一句警告:
RuntimeWarning: coroutine 'fetch' was never awaited
协程对象自己不会跑,得有人驱动它。这个人就是事件循环。CPython 里 asyncio.run() 干的第一件事,就是把最外层那个协程包装成 Task,交给循环。
顺手记一下:既然是对象,就能被存起来、放进列表、传给别的函数。这跟普通函数调用「立刻拿结果」的语义,是两种不同的东西。
三、await 让出的是控制权,不是 CPU
反汇编一个最小协程,看看 await 处生成了什么(CPython 3.13):
import dis
async def inner():
await asyncio.sleep(0)
dis.dis(inner)
关键几条指令是 GET_AWAITABLE、SEND、YIELD_VALUE、END_SEND。名字已经把机制说透了:SEND 把值送进被等待的对象,YIELD_VALUE 把当前协程挂起并产出一个值给调用方。
所以 await 的完整语义是:
- 拿到后面那个对象的「可等待」版本;
- 把当前协程执行到一半的现场(局部变量、指令指针、栈)连同控制权一起交回去;
- 交回给谁?交给驱动它的 Task / 事件循环;
- 等到它等的那件事(IO 就绪、定时器到期)完成了,循环再把现场恢复,从
YIELD_VALUE后面接着跑。
注意第 3 步:控制权是在 Python 代码内部交接的,没有陷入内核,没有线程切换,没有 GIL 释放。这跟 time.sleep() 那种「把整个线程交给操作系统去睡」完全不是一回事。
我更愿意把 await 读成「我先让一让,好了叫我」,而不是「等待」。它是一次主动的、有记录的让出。
四、事件循环在干什么
把它想成一个不停转的单线程调度器,手里攥着两样东西:
- 就绪队列:现在可以往下跑的 Task;
- IO 多路复用器:Linux 上是 epoll,Windows 上是 IOCP / select,负责盯住一堆 socket 和定时器。
循环每一轮做三件事:
- 从就绪队列取一个 Task,驱动它跑到下一个
await; - Task 让出时,把它挂起,把它在等的那个 fd 或定时器登记到多路复用器;
- 队列空了就调
selector.select()阻塞等待;哪个 IO 好了,把对应 Task 放回就绪队列。
伪代码长这样:
while True:
while ready:
task = ready.popleft()
task.step() # 跑到下一个 await / 结束
timeout = next_timer_deadline()
events = selector.select(timeout)
for ev in events:
ready.append(task_of(ev))
关键在第 3 步:整条线程只在「确实没有任何任务可跑」时才阻塞。只要有一个协程在等网络,循环就能转头去跑别的协程。1000 个 asyncio.sleep(0.02) 之所以 0.049 秒跑完,是因为它们几乎同时把定时器登记进去,然后一起等,20 毫秒后一起被唤醒。
五、为什么 CPU 密集和阻塞调用白搭
单线程调度有个硬约束:任何一个 Task 不让出,整条线程就归它了。
import time
async def blocked():
time.sleep(0.02) # 阻塞调用,没有 await
async def main():
await asyncio.gather(*(blocked() for _ in range(20)))
我实测 main() 耗时 0.421 秒,正好约等于 20 × 0.02 秒的串行总和。一个协程都没并发起来。原因是 time.sleep 里没有 await,事件循环压根没拿到过控制权。
同理,纯计算(大循环、加密、图像处理)也不会让出。这类活要么扔进 asyncio.to_thread() / run_in_executor,要么直接上多进程。
一句话:asyncio 加速的是「等」,不是「算」。
六、四个最常见的坑
| 坑 | 表现 | 改法 |
|---|---|---|
| 协程里用了阻塞库 | requests.get、time.sleep、同步数据库驱动,并发全失效 |
换 aiohttp / asyncpg,或 await asyncio.to_thread(...) |
忘了 await |
拿到 coroutine 对象当结果用,报 object ... can't be used in 'await' expression 或 RuntimeWarning |
每个协程调用都检查 |
在普通 def 里写 await |
直接 SyntaxError |
想 await,函数本身必须是 async def |
| 一个 Task 里塞 CPU 密集循环 | 其他 Task 集体卡住 | 拆成小块定期 await asyncio.sleep(0),或推到线程池 |
await asyncio.sleep(0) 这行看着没用,其实是「显式让出一次」的标准写法,长循环里加一句能让其他 Task 有机会跑。
七、三种并发模型怎么选
| 模型 | 调度者 | 适合 | 不适合 |
|---|---|---|---|
threading |
操作系统/GIL | 少量阻塞 IO、调用同步库 | 上千连接、CPU 密集 |
multiprocessing |
操作系统 | CPU 密集、绕开 GIL | 高并发 IO、共享状态多 |
asyncio |
用户态事件循环 | 上千并发网络 IO、长连接 | CPU 密集、阻塞式第三方库 |
别把它们当竞争对手。真实项目里常见的组合是:asyncio 管网络收发,线程池兜住几个阻塞调用,多进程扛 CPU 计算。
八、一句话记住
async def 造出的是一个可暂停的函数对象;await 是它主动把现场和控制权交回事件循环的那一次让出;事件循环靠一个就绪队列加 IO 多路复用,把所有「在等」的时间叠到一起。
所以 1000 个任务能同时跑,不是因为 Python 变快了,是因为它们大部分时间根本没在跑,只是在等,而循环没陪着一起等。
(实测环境:CPython 3.13.14,Windows;文中耗时与 dis 输出均可直接复现)