跳到内容

第 9 卷 / 第 3 章 / 第 3 课

3.3 任务并发:Async、结构化并发与 STM ​

锁和消息模型主要回答“怎样协调状态”;任务模型还要回答“并发工作活多久、失败怎样传播、父任务何时算完成”。很多线上泄漏并非数据竞争,而是请求已经结束,子任务还在后台运行。

Async/Await 是挂起协议,不是自动并行 ​

调用 Python 的 async def 函数得到协程对象,这一步不会运行函数体;await 或调度它才开始工作。JavaScript 的 async 函数返回 Promise,Rust 的 async 函数产生需要轮询的 Future。等待对象尚未就绪时,任务可挂起并让调度器运行其他工作;await 已就绪的结果不一定会挂起。

python
async def load_dashboard(user_id: str):
    profile, orders = await asyncio.gather(
        load_profile(user_id),
        load_orders(user_id),
    )
    return build_dashboard(profile, orders)

上面的 dashboard 是调用片段,需要 import asyncio 并补齐三个应用函数。默认 gather 会把首个异常传给调用方,但不会自动取消其他子任务;取消 gather 本身又是另一种行为。若策略要求兄弟任务一并取消和清理,应使用下面的 TaskGroup。

这个模型适合大量等待 I/O 的任务,因为挂起不必为每个连接长期占住一个操作系统线程。但要区分三个概念:

  • 并发:多个任务的生命周期重叠;
  • 并行:多个任务在同一时刻真正执行;
  • 异步:发起操作后不以阻塞当前执行体的方式等待结果。

在单线程事件循环中,两个协程可以并发,但 Python 代码片段仍轮流执行。若协程中直接进行长时间 CPU 计算或阻塞式 I/O,整个循环都会被拖住。应将其切分、改用非阻塞接口,或交给受控的线程/进程池。

非结构化任务为什么难以推理 ​

python
async def handle_request(request):
    asyncio.create_task(write_audit_log(request))
    return {"ok": True}

这段代码立即返回,但留下了一串问题:

  • 审计失败由谁接收异常?
  • 请求取消后,审计任务是否继续?
  • 进程关闭时是否等待它完成?
  • 任务捕获的请求对象会占用多久内存?

跨越请求生命周期的工作必须有明确所有者。若要在进程故障后保留工作,就需要持久队列与重试策略;有意设计为临时工作的任务,也可由进程内任务集合管理,但要持有强引用、观察异常,并在关闭时等待或取消。上面的片段没有做到这些。

结构化并发:让任务形成树 ​

结构化并发要求子任务的生命周期受一个清晰作用域约束:父任务离开作用域之前,要么取得子任务结果,要么取消并等待它们收尾。

text
处理请求
├── 查询账户
├── 查询订单
└── 查询推荐

父任务成功:按策略汇总结果
任一关键子任务失败:取消兄弟任务并传播失败
父任务超时:取消整棵子任务树

它带来的关键收益是:

  • 生命周期可以从代码块直接看出;
  • 失败、取消和截止时间沿任务树传播;
  • 调试器和监控可以展示父子关系;
  • 不容易留下无人等待的孤儿任务。

Java SE 26 的 StructuredTaskScope 仍是 Preview API。下面片段需要导入 java.util.concurrent.StructuredTaskScope、补齐应用方法与结果类型,并在外层方法处理中断。编译和运行都要使用 JDK 26 并启用预览(javac --enable-preview --release 26 ...、java --enable-preview ...)。默认 open 策略在子任务失败时取消未结束的兄弟任务;其他 joiner 可采用不同策略:

java
try (var scope = StructuredTaskScope.open()) {
    var profile = scope.fork(() -> loadProfile(userId));
    var orders = scope.fork(() -> loadOrders(userId));

    scope.join();
    return new Dashboard(profile.get(), orders.get());
}

结构化并发不等于虚拟线程。虚拟线程改变任务映射到平台线程的成本;结构化并发约束任务之间的生命周期关系。两者可以配合,也可以分别存在。

取消通常也是协作式协议:收到中断或取消信号的任务必须尽快停止阻塞、释放资源并结束。忽略中断的子任务可能让作用域永远无法关闭,即使超时已经触发取消。

观察兄弟任务取消与清理 ​

把下面代码保存为 task_group_demo.py,使用 Python 3.11 或更高版本执行 python3 task_group_demo.py。Event 保证等待任务已经开始,另一个任务才抛错,不需要靠猜测延迟安排顺序:

python
import asyncio

async def main():
    started = asyncio.Event()
    cleaned = asyncio.Event()

    async def slow():
        try:
            started.set()
            await asyncio.Event().wait()
        finally:
            cleaned.set()

    async def fail():
        await started.wait()
        raise RuntimeError("dependency failed")

    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(slow(), name="slow-query")
            group.create_task(fail(), name="failing-query")
    except* RuntimeError as errors:
        print("failures", len(errors.exceptions))
    print("cleaned", cleaned.is_set())

asyncio.run(main())

输出依次是 failures 1、cleaned True。TaskGroup 取消等待任务,并在它的 finally 执行后才向外传播异常组。实际任务不要吞掉 CancelledError。超时只是发出取消请求,清理逻辑如果阻塞或忽略取消,返回仍会拖延;作用域管理的是加入其中的任务,不会自动接管别处启动的后台工作。

STM:把共享状态更新写成事务 ​

软件事务内存(STM)允许程序在事务中读取和修改受管理的共享引用:若提交时发现冲突,实现可以重试事务,使一组修改表现为原子提交。

下面是可在 Clojure 1.12 REPL 中执行的固定金额转账例子:

clojure
(def checking (ref 1000))
(def savings  (ref 500))

(dosync
  (alter checking - 200)
  (alter savings  + 200))

执行后求值 [@checking @savings] 得到 [800 700],总额仍为 1500。这笔固定转账余额充足;通用扣款还应在同一事务内检查金额与余额。

事务语义并不限定一种固定实现算法。STM 可以采用乐观、悲观或混合策略;Clojure 的 STM 使用多版本并发控制和快照隔离,但不能据此把所有 STM 都定义成 MVCC。

事务可能自动重试,因此事务体应避免不可撤销的外部副作用:

clojure
;; 不要在可能重试的事务中直接发送邮件或调用支付接口
(dosync
  (alter balance - amount)
  (send-payment-email))

否则一次源代码调用可能产生多次外部动作。不可撤销的 I/O 应放在可重试事务之外。提交后触发动作可以避免事务内部重放,却留下“已提交但尚未执行外部动作”的崩溃窗口。持久交付还需要持久化交接与幂等处理,提交进程内 Ref 并不能提供这个保证。

STM 的优势是组合多个受管状态时表达自然;代价是冲突下的重复执行、性能不确定性、调试难度以及副作用限制。它不是数据库事务的替代品,也不能跨进程自动提供一致性。

用约束选择模型 ​

问题形态候选模型首要验证点
少量共享状态、复合不变量锁 / 数据库事务临界区和锁顺序
高频单变量状态转换原子操作竞争、重试和内存顺序
具有身份和私有状态的实体Actor邮箱上限、幂等和恢复
多阶段数据流水线Channel / CSP背压、关闭与取消
大量 I/O 等待Async/Await阻塞调用和任务泄漏
请求拆分为多个子查询结构化并发失败策略和截止时间
进程内多个受管引用原子更新STM冲突率和副作用隔离

模型可以组合,但每增加一种模型,调试和观测成本都会上升。一个服务同时混用线程池、事件循环、Actor 和回调队列时,必须明确它们之间的所有权边界,否则“哪里都能异步”最终会变成“没人知道任务在哪里”。

并发代码的验证清单 ​

  • 用压力测试扩大竞态窗口,但不把“跑了一万次没出错”当证明;
  • 为超时、取消、重复消息、队列满和部分失败写测试;
  • 记录队列深度、任务年龄、拒绝数、取消数和执行时长,而不只看线程数;
  • 在线程转储或任务追踪中保留可识别的任务名称;
  • 对每个异步边界说明所有者、容量、失败处理和关闭协议。

完成检查 ​

一个接口要并发查询三家供应商,取第一个满足价格条件的结果。请设计:

  1. 总截止时间和单供应商截止时间;
  2. 获得结果后怎样取消其余任务;
  3. 所有供应商失败时返回哪个错误;
  4. 供应商忽略取消时怎样隔离资源;
  5. 如何在追踪中看见整个任务树。

能回答这些问题,比把顺序调用改成三个 async 更接近生产级并发。

参考资料 ​

Built with VitePress | Software Systems Atlas