3.3 任务并发:Async、结构化并发与 STM
锁和消息模型主要回答“怎样协调状态”;任务模型还要回答“并发工作活多久、失败怎样传播、父任务何时算完成”。很多线上泄漏并非数据竞争,而是请求已经结束,子任务还在后台运行。
Async/Await 是挂起协议,不是自动并行
调用 Python 的 async def 函数得到协程对象,这一步不会运行函数体;await 或调度它才开始工作。JavaScript 的 async 函数返回 Promise,Rust 的 async 函数产生需要轮询的 Future。等待对象尚未就绪时,任务可挂起并让调度器运行其他工作;await 已就绪的结果不一定会挂起。
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,整个循环都会被拖住。应将其切分、改用非阻塞接口,或交给受控的线程/进程池。
非结构化任务为什么难以推理
async def handle_request(request):
asyncio.create_task(write_audit_log(request))
return {"ok": True}这段代码立即返回,但留下了一串问题:
- 审计失败由谁接收异常?
- 请求取消后,审计任务是否继续?
- 进程关闭时是否等待它完成?
- 任务捕获的请求对象会占用多久内存?
跨越请求生命周期的工作必须有明确所有者。若要在进程故障后保留工作,就需要持久队列与重试策略;有意设计为临时工作的任务,也可由进程内任务集合管理,但要持有强引用、观察异常,并在关闭时等待或取消。上面的片段没有做到这些。
结构化并发:让任务形成树
结构化并发要求子任务的生命周期受一个清晰作用域约束:父任务离开作用域之前,要么取得子任务结果,要么取消并等待它们收尾。
处理请求
├── 查询账户
├── 查询订单
└── 查询推荐
父任务成功:按策略汇总结果
任一关键子任务失败:取消兄弟任务并传播失败
父任务超时:取消整棵子任务树它带来的关键收益是:
- 生命周期可以从代码块直接看出;
- 失败、取消和截止时间沿任务树传播;
- 调试器和监控可以展示父子关系;
- 不容易留下无人等待的孤儿任务。
Java SE 26 的 StructuredTaskScope 仍是 Preview API。下面片段需要导入 java.util.concurrent.StructuredTaskScope、补齐应用方法与结果类型,并在外层方法处理中断。编译和运行都要使用 JDK 26 并启用预览(javac --enable-preview --release 26 ...、java --enable-preview ...)。默认 open 策略在子任务失败时取消未结束的兄弟任务;其他 joiner 可采用不同策略:
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 保证等待任务已经开始,另一个任务才抛错,不需要靠猜测延迟安排顺序:
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 中执行的固定金额转账例子:
(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。
事务可能自动重试,因此事务体应避免不可撤销的外部副作用:
;; 不要在可能重试的事务中直接发送邮件或调用支付接口
(dosync
(alter balance - amount)
(send-payment-email))否则一次源代码调用可能产生多次外部动作。不可撤销的 I/O 应放在可重试事务之外。提交后触发动作可以避免事务内部重放,却留下“已提交但尚未执行外部动作”的崩溃窗口。持久交付还需要持久化交接与幂等处理,提交进程内 Ref 并不能提供这个保证。
STM 的优势是组合多个受管状态时表达自然;代价是冲突下的重复执行、性能不确定性、调试难度以及副作用限制。它不是数据库事务的替代品,也不能跨进程自动提供一致性。
用约束选择模型
| 问题形态 | 候选模型 | 首要验证点 |
|---|---|---|
| 少量共享状态、复合不变量 | 锁 / 数据库事务 | 临界区和锁顺序 |
| 高频单变量状态转换 | 原子操作 | 竞争、重试和内存顺序 |
| 具有身份和私有状态的实体 | Actor | 邮箱上限、幂等和恢复 |
| 多阶段数据流水线 | Channel / CSP | 背压、关闭与取消 |
| 大量 I/O 等待 | Async/Await | 阻塞调用和任务泄漏 |
| 请求拆分为多个子查询 | 结构化并发 | 失败策略和截止时间 |
| 进程内多个受管引用原子更新 | STM | 冲突率和副作用隔离 |
模型可以组合,但每增加一种模型,调试和观测成本都会上升。一个服务同时混用线程池、事件循环、Actor 和回调队列时,必须明确它们之间的所有权边界,否则“哪里都能异步”最终会变成“没人知道任务在哪里”。
并发代码的验证清单
- 用压力测试扩大竞态窗口,但不把“跑了一万次没出错”当证明;
- 为超时、取消、重复消息、队列满和部分失败写测试;
- 记录队列深度、任务年龄、拒绝数、取消数和执行时长,而不只看线程数;
- 在线程转储或任务追踪中保留可识别的任务名称;
- 对每个异步边界说明所有者、容量、失败处理和关闭协议。
完成检查
一个接口要并发查询三家供应商,取第一个满足价格条件的结果。请设计:
- 总截止时间和单供应商截止时间;
- 获得结果后怎样取消其余任务;
- 所有供应商失败时返回哪个错误;
- 供应商忽略取消时怎样隔离资源;
- 如何在追踪中看见整个任务树。
能回答这些问题,比把顺序调用改成三个 async 更接近生产级并发。