加上 async,为什么有时快了,有时只是多了一个关键字?¶
AI 学习助手准备生成学习计划。它要读取用户偏好和课程目录,再做一小段本地整理。
同步版本每次读取都要等待:程序没有计算,却也不能处理别的工作。直觉上这正是
async 擅长的场景。但如果把本地排序也改成 async def,它会更快吗?答案藏在
“程序究竟在等什么”里,而不在函数名前有没有 async。
运行环境¶
本章使用 Python 3.12 和标准库 asyncio。延迟由 sleep 模拟,只用于稳定展示
等待能否重叠,不代表真实网络耗时。
1. 同步等待:CPU 空着,调用链却走不了¶
import time
def load(name: str, delay: float) -> str:
time.sleep(delay)
return f"{name}:ok"
started = time.perf_counter()
profile = load("profile", 0.15)
catalog = load("catalog", 0.15)
print(profile, catalog, round(time.perf_counter() - started, 2))
两个调用约需 0.30 秒。等待期间,CPU 并没有替远端完成工作;当前线程只是停在
time.sleep。真实 API、数据库和文件 I/O 也常包含这种“发出请求后等结果”的
区间。若服务同时处理多个互不依赖的等待,串行等待会浪费可推进其他工作的机会。
把这段执行画成时间线,浪费的位置就很直观:
时间 ─────────────────────────────────────────────────────▶
profile [===== 0.15s =====]
catalog [===== 0.15s =====]
总计 0.30s
两段等待首尾相接,中间没有任何计算被填进去。
这里先区分两个词:并发表示多项工作在同一时间段内交替推进;并行表示同一时刻 真的有多项计算在不同执行单元上运行。asyncio 主要解决前者,尤其是 I/O 等待。
2. async 的收益来自等待点¶
import asyncio
import time
async def load(name: str, delay: float) -> str:
await asyncio.sleep(delay)
return f"{name}:ok"
async def main() -> None:
started = time.perf_counter()
profile, catalog = await asyncio.gather(
load("profile", 0.15),
load("catalog", 0.15),
)
print(profile, catalog, round(time.perf_counter() - started, 2))
asyncio.run(main())
总时间接近 0.15 秒。要注意变化的不是单个请求:每次 load 仍然等了 0.15 秒,
远端耗时一点没少。变的是两段等待的排布——它们重叠了。所以收益的准确说法是:
总耗时接近最慢的那一段等待,而不是所有等待之和。
重叠是怎么发生的?第一个任务开始等待时,它把执行机会交还给一个调度者;调度者 转而启动第二个任务,于是第二段等待在第一段还没结束时就已经开始。这个调度者的 正式名称是 event loop。本章只需要它的一条性质:等待发生时,由它决定接下来 推进哪个任务。它如何在单线程中管理这些任务、切换点具体落在哪里、任务由谁 负责回收,下一章展开。
让出的前提是函数体里存在等待点,而 await 标记的正是这些位置:函数在此暂停,
稍后从原处恢复。这种可以中途暂停的函数称为 coroutine(同样在下一章展开)。若
函数体从头到尾没有 await,它就没有主动让出控制权的机会;写成 async def 也
不会凭空获得并发收益。
因此“这是 I/O”仍不足以决定使用 async。还要问:是否存在有意义的并发规模? 调用之间是否独立?依赖库是否提供真正可等待的接口?引入异步调用链的复杂度是否 值得?一个每天只运行一次、严格顺序的小脚本,保持同步可能更清楚。
3. 短本地计算与 CPU 密集工作不是同一问题¶
计划标题清洗、字段校验、拼接少量字符串通常很快。把它们写成普通函数,直接在 coroutine 中调用即可。为了“全 async”而包装它们,只增加 coroutine 管理成本。
CPU 密集循环则是另一端:它长时间占用 CPU,而且没有等待点。改成 async def
不会让计算分散到多个核心,也不会自动加速;反而可能长时间占住 event loop。
这类工作通常需要不同执行模型,本课只要求识别边界,不展开进程池设计。
可以用四问做初筛:
- 时间主要花在等待外部资源,还是执行本地计算?
- 等待期间是否有其他独立工作可以推进?
- 使用的库是否真正支持 async,而不是在 coroutine 里同步等待?
- 并发收益是否足以抵消更复杂的控制流?
边界与常见误区¶
- “I/O 都必须 async”不成立。低并发、延迟不敏感或必须严格顺序的路径可能更适合同步。
- “async 等于多线程或多核并行”不成立。单线程内的交替推进不是 CPU 并行。
- “async def 里面的调用都会非阻塞”不成立。同步阻塞函数仍会占住所在的线程。
- “同步代码都不该出现在 async 函数里”也不成立。短本地计算直接完成最简单。
本章小结¶
async 是等待管理工具,不是通用加速按钮。它的价值来自:任务遇到可等待的 I/O 时让出控制权,让其他就绪任务推进。判断前先区分短本地计算、可重叠 I/O、有依赖 I/O 和阻塞调用;并发需求、库能力与复杂度共同决定是否值得异步化。
练习¶
- 分类:读取两个独立 API、校验一个短字符串、计算一百万次哈希、读取 API 后用 结果查询详情,分别适合什么执行关系?
- 为什么把 CPU 密集函数改成
async def不会自动变快? - 一个低流量管理脚本只有一次远端调用,是否必须改成 async?列出决策依据。
练习解析¶
- 两个独立 API 可以在确认副作用独立后并发;短字符串校验保持同步;大量哈希是 CPU 密集工作,应考虑其他执行模型;详情查询依赖首次结果,必须保持顺序。
- CPU 密集函数没有等待点,不能让出控制权让其他任务推进,也不会自动获得多核 并行。它仍持续占用执行线程。
- 不必须。若没有并发需求,异步化不会缩短这一个请求自身的远端耗时,还会增加 调用链复杂度。应同时考虑未来并发、依赖库接口和维护成本。