跳转至

会 await 不等于会并发:先画依赖,再写 gather

学习助手生成计划要做四件事:读取用户偏好、读取课程目录、用两份数据生成计划、 保存计划。把四个调用一起放进 gather 看起来最“异步”,却会让生成操作拿不到 输入,让保存发生在计划产生之前。并发原语能调度工作,但不能替我们理解业务。

运行环境

Python 3.12、asyncio 标准库。示例用可控延迟模拟独立 I/O。

1. 用数据流决定并发层

先不写代码,只画箭头:

load_profile ─┐
              ├─> build_plan ─> save_plan
load_catalog ─┘

两个读取所需输入一开始就具备,互不使用对方结果,可以位于同一层。build_plan 需要两份结果,save_plan 又需要生成结果,所以后两步必须顺序执行。这与 ETL DAG 很像:同层节点可能并行,箭头连接的节点不能颠倒。

数据参数独立还不够。两个调用若同时写同一状态、共享非线程安全资源,或业务要求 “先持久化再通知”,仍可能必须顺序。信息不足时,正确答案是“先确认副作用”, 而不是猜测可以并发。

2. 把同层 I/O 组合,跨层依赖保留

import asyncio

async def load(name: str, delay: float) -> str:
    await asyncio.sleep(delay)
    return f"{name}:ok"

async def build_study_plan() -> dict[str, str]:
    profile, catalog = await asyncio.gather(
        load("profile", 0.12),
        load("catalog", 0.04),
    )
    plan = f"plan({profile}, {catalog})"       # 短本地计算
    saved = await load(f"save:{plan}", 0.02)  # 依赖 plan
    return {"profile": profile, "catalog": catalog, "saved": saved}

gather 并发调度两个 awaitable,等全部完成后返回。这里使用它,是因为依赖分析 已经证明两个读取属于同层,而不是因为函数写着 async。生成计划是短本地计算, 无需伪装成 coroutine;保存依赖生成结果,必须位于下一层。

本课只使用 gather 的正常成功路径。一个子任务失败后兄弟任务如何处置、是否允许 部分结果,是后续异常与取消课程的内容,不能从这个示例擅自推导生产策略。

3. 完成顺序不能决定业务身份

上例中 catalog 先完成,profile 后完成,但 gather(profile_call, catalog_call) 的 返回顺序与传入 awaitable 的顺序一致,不按完成快慢重新排列。因此慢的 profile 仍解包到 profile

当调用数量动态变化时,位置可能不够直观,应保存显式身份:

calls = {
    "profile": load("profile", 0.12),
    "catalog": load("catalog", 0.04),
}
values = await asyncio.gather(*calls.values())
results = dict(zip(calls.keys(), values, strict=True))

核心原则不是必须用字典,而是业务身份不能依赖不可预测的完成顺序。否则某次网络 抖动就可能把“用户偏好”当成“课程目录”。

4. 并发块也需要所有者

当前 service operation 创建的并发工作应在函数返回前完成。审查时问四件事:

  1. 谁创建这些工作?
  2. 谁等待它们?
  3. 什么条件表示这一组工作完成?
  4. 每个结果怎样回到正确业务身份?

若代码 create_task 后直接 return,依赖图即使画对了,任务仍逃逸了。后台托管是 另一种产品语义,需要独立设计,不能由“忘了 await”偶然得到。

边界与常见误区

  • 所有调用都是 async,不代表它们相互独立。
  • 连续 await 保持顺序;是否应改为并发由依赖决定,而不是耗时决定。
  • 可并发不等于必须并发;简单、低流量路径可能选择顺序执行。
  • 不按完成先后猜结果身份。
  • gather 不是依赖分析器,也不是本课的异常处理方案。

本章小结

正确顺序是:先画数据与副作用依赖,再找出可重叠的同层 I/O,最后选择执行原语。 依赖调用保持顺序,结果保持稳定身份,所有任务留在明确父作用域中。API 只是把 设计落地,不能替代设计。

练习

  1. “读取用户”“读取用户订单”“根据订单推荐课程”中哪些可以并发?还缺什么信息?
  2. 修正:plan, profile = await gather(load_profile(), build_plan())
  3. 两个调用延迟每次变化,怎样保证结果不会错配?

练习解析

  1. 若读取订单需要用户 ID,则必须先读取用户;推荐又依赖订单。若 ID 已由请求直接 提供,前两个读取是否可并发还要检查共享资源和副作用。
  2. 先取得构建计划所需的全部输入;只有独立读取进入 gather,build_plan 在其后 执行。不能靠调换变量名修复不存在的输入。
  3. 使用 gather 的声明顺序、显式键或任务到业务 ID 的映射;不要用完成顺序命名。

参考资料