Python 的 async def 为什么能同时跑几千个任务?拆开协程对象、await 与事件循环那次让出

先看一组我在自己机器上跑出来的数:

python 复制代码
import asyncio, time

async def one():
    await asyncio.sleep(0.02)

await asyncio.gather(*(one() for _ in range(1000)))   # 0.049 s

for _ in range(1000):
    await one()                                       # 30.953 s

差了 626 倍。整个过程没开一个线程,没起一个进程,就是一个线程在那儿转。

很多人把这件事理解成「asyncio 比线程快」,其实不对。快的是等待被重叠了 ,不是代码跑得更快。要弄清这一点,得从 async def 到底造出了什么开始拆。

一、async def 定义的是协程函数,不是普通函数

python 复制代码
import inspect

async def fetch():
    return 42

print(inspect.iscoroutinefunction(fetch))   # True
print(type(fetch))                          # <class 'function'>

函数还是那个函数对象,只是被打了个标记:CO_COROUTINE。这个标记决定了调用它的时候,解释器不会立刻执行函数体。

二、调用它,函数体一行都不会跑

这是新手最容易懵的地方:

python 复制代码
c = fetch()
print(type(c))            # <class 'coroutine'>
print(inspect.iscoroutine(c))   # True

fetch() 返回的是一个协程对象 ,一串还没启动的字节码。此时函数体里哪怕写着 print("hello"),也不会输出。

直接这么写,Python 会给你一句警告:

lua 复制代码
RuntimeWarning: coroutine 'fetch' was never awaited

协程对象自己不会跑,得有人驱动 它。这个人就是事件循环。CPython 里 asyncio.run() 干的第一件事,就是把最外层那个协程包装成 Task,交给循环。

顺手记一下:既然是对象,就能被存起来、放进列表、传给别的函数。这跟普通函数调用「立刻拿结果」的语义,是两种不同的东西。

三、await 让出的是控制权,不是 CPU

反汇编一个最小协程,看看 await 处生成了什么(CPython 3.13):

python 复制代码
import dis

async def inner():
    await asyncio.sleep(0)

dis.dis(inner)

关键几条指令是 GET_AWAITABLE、SEND、YIELD_VALUE、END_SEND。名字已经把机制说透了:SEND 把值送进被等待的对象,YIELD_VALUE 把当前协程挂起并产出一个值给调用方。

所以 await 的完整语义是:

  1. 拿到后面那个对象的「可等待」版本;
  2. 把当前协程执行到一半的现场(局部变量、指令指针、栈)连同控制权一起交回去;
  3. 交回给谁?交给驱动它的 Task / 事件循环;
  4. 等到它等的那件事(IO 就绪、定时器到期)完成了,循环再把现场恢复,从 YIELD_VALUE 后面接着跑。

注意第 3 步:控制权是在 Python 代码内部 交接的,没有陷入内核,没有线程切换,没有 GIL 释放。这跟 time.sleep() 那种「把整个线程交给操作系统去睡」完全不是一回事。

我更愿意把 await 读成「我先让一让,好了叫我」,而不是「等待」。它是一次主动的、有记录的让出。

四、事件循环在干什么

把它想成一个不停转的单线程调度器,手里攥着两样东西:

  • 就绪队列:现在可以往下跑的 Task;
  • IO 多路复用器:Linux 上是 epoll,Windows 上是 IOCP / select,负责盯住一堆 socket 和定时器。

循环每一轮做三件事:

  1. 从就绪队列取一个 Task,驱动它跑到下一个 await;
  2. Task 让出时,把它挂起,把它在等的那个 fd 或定时器登记到多路复用器;
  3. 队列空了就调 selector.select() 阻塞等待;哪个 IO 好了,把对应 Task 放回就绪队列。

伪代码长这样:

python 复制代码
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 不让出,整条线程就归它了。

python 复制代码
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 输出均可直接复现)

相关推荐
小小龙学IT1 小时前
Go 语言 pprof 性能剖析与调优深度解析:把“卡在哪“变成一张可执行的数据图
开发语言·后端·golang
Sailing1 小时前
🚀🚀【封闭结束8】Agent RAG:从零搭建一个可用知识库
前端·后端·agent
ZZHow10241 小时前
神经网络与深度学习笔记_Day1
人工智能·pytorch·笔记·python·深度学习·神经网络·机器学习
Rain的Java大神之路1 小时前
面试官让你把 LLM、RAG、Agent、MCP 串起来讲清楚,90% 的 Java 工程师当场露馅
java·后端·面试
余槐i1 小时前
Spring Boot 3.2 偶发500无日志:线程池复用导致请求上下文丢失
spring boot·redis·后端·mysql·性能优化
北城笑笑1 小时前
Vue 106 ,上篇:前端桌面端应用实现方案( Electron 框架详细介绍,Vue + Electron )
前端·javascript·vue.js·python·electron
青少儿编程课堂2 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会C/C++考级:分层图最短路解析
c++·python·算法·bfs·信息学竞赛
全栈练习生2 小时前
大模型原理之自适应推理
python·ai