跳到内容

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

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

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

async 函数通常返回一个 Future、Promise 或 Task;执行到尚未就绪的 await 时,它保存状态并把执行权交还调度器。等待的 I/O 就绪后,任务从挂起点继续。

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)

这个模型适合大量等待 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
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());
}

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

取消通常也是协作式协议:收到中断或取消信号的任务必须尽快停止阻塞、释放资源并结束。忽略中断的子任务会让作用域关闭长时间等待。

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

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

以 Clojure 的 Ref 为例:

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

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

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

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

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

否则一次源代码调用可能产生多次外部动作。正确做法通常是让事务只计算并提交内部状态,再在提交后通过可靠机制触发副作用。

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

用约束选择模型

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

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

并发代码的验证清单

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

完成检查

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

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

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

参考资料

Built with VitePress | Software Systems Atlas