AI图鉴

AI图鉴首页 › AI 资讯 › Python 的 async def 为什么能同时跑几千个任务?拆开协程对象、a

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

来源:掘金 · 发布时间:11-04 · 由 AI图鉴每小时自动聚合

先看一组我在自己机器上跑出来的数: 差了 626 倍。整个过程没开一个线程,没起一个进程,就是一个线程在那儿转。 很多人把这件事理解成「asyncio 比线程快」,其实不对。快的是等待被重叠了,不是代

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

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 定义的是协程函数,不是普通函数

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 加速的是「等」,不是「算」。

六、四个最常见的坑

await asyncio.sleep(0) 这行看着没用,其实是「显式让出一次」的标准写法,长循环里加一句能让其他 Task 有机会跑。

七、三种并发模型怎么选

别把它们当竞争对手。真实项目里常见的组合是:asyncio 管网络收发,线程池兜住几个阻塞调用,多进程扛 CPU 计算。

八、一句话记住

async def 造出的是一个可暂停的函数对象;await 是它主动把现场和控制权交回事件循环的那一次让出;事件循环靠一个就绪队列加 IO 多路复用,把所有「在等」的时间叠到一起。

所以 1000 个任务能同时跑,不是因为 Python 变快了,是因为它们大部分时间根本没在跑,只是在等,而循环没陪着一起等。

(实测环境:CPython 3.13.14,Windows;文中耗时与 dis 输出均可直接复现)

本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。

相关 AI 工具

相关免费羊毛

同源资讯

阅读原文(juejin.cn)

标题、摘要与正文均来自该信源的公开页面,版权归原作者所有;本站自动聚合与翻译,仅作出处跳转。

← 返回 AI 资讯列表AI工具图鉴首页

返回 AI图鉴首页 · 提交工具 · 商务合作 · 关于本站 · 609 款 AI 工具分类导航 · 每日 AI 热门项目与动态