跳转至

加上 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())
时间 ──────────────────────────────▶
profile  [===== 0.15s =====]
catalog  [===== 0.15s =====]
                       总计 0.15s

总时间接近 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。 这类工作通常需要不同执行模型,本课只要求识别边界,不展开进程池设计。

可以用四问做初筛:

  1. 时间主要花在等待外部资源,还是执行本地计算?
  2. 等待期间是否有其他独立工作可以推进?
  3. 使用的库是否真正支持 async,而不是在 coroutine 里同步等待?
  4. 并发收益是否足以抵消更复杂的控制流?

边界与常见误区

  • “I/O 都必须 async”不成立。低并发、延迟不敏感或必须严格顺序的路径可能更适合同步。
  • “async 等于多线程或多核并行”不成立。单线程内的交替推进不是 CPU 并行。
  • “async def 里面的调用都会非阻塞”不成立。同步阻塞函数仍会占住所在的线程。
  • “同步代码都不该出现在 async 函数里”也不成立。短本地计算直接完成最简单。

本章小结

async 是等待管理工具,不是通用加速按钮。它的价值来自:任务遇到可等待的 I/O 时让出控制权,让其他就绪任务推进。判断前先区分短本地计算、可重叠 I/O、有依赖 I/O 和阻塞调用;并发需求、库能力与复杂度共同决定是否值得异步化。

练习

  1. 分类:读取两个独立 API、校验一个短字符串、计算一百万次哈希、读取 API 后用 结果查询详情,分别适合什么执行关系?
  2. 为什么把 CPU 密集函数改成 async def 不会自动变快?
  3. 一个低流量管理脚本只有一次远端调用,是否必须改成 async?列出决策依据。

练习解析

  1. 两个独立 API 可以在确认副作用独立后并发;短字符串校验保持同步;大量哈希是 CPU 密集工作,应考虑其他执行模型;详情查询依赖首次结果,必须保持顺序。
  2. CPU 密集函数没有等待点,不能让出控制权让其他任务推进,也不会自动获得多核 并行。它仍持续占用执行线程。
  3. 不必须。若没有并发需求,异步化不会缩短这一个请求自身的远端耗时,还会增加 调用链复杂度。应同时考虑未来并发、依赖库接口和维护成本。

参考资料