结构化并发¶
结构化并发把并发任务限制在词法或动态作用域中:父任务离开作用域前,子任务必须完成、失败或被取消。任务因而形成树,而不是一组失去归属的后台句柄。
非结构化任务的问题¶
返回后两个任务:
- 还引用 request 的内存吗?
- 失败由谁观察?
- 客户端取消时继续吗?
- 服务关闭时谁等待它们?
- deadline 到期后是否仍写外部系统?
若答案散落在全局 registry、回调和日志中,生命周期已经脱离控制流结构。
任务树¶
基本约束:
- parent 拥有 child 的生命周期;
- scope 结束前 join 所有 child;
- child 失败向 parent 传播;
- parent 取消向后代传播;
- 资源按作用域逆序释放;
- detached task 必须成为显式、较长生命周期服务的孩子。
这把并发的 goto 变回可局部推理的块结构。
fail-fast 不是唯一策略¶
子任务失败后常见语义:
- fail-fast:取消 siblings,聚合错误后退出;
- collect-all:等待全部,返回每项成功/失败;
- quorum:达到足够成功即取消剩余;
- supervisor:child 失败不自动取消 siblings;
- restart:由上层监督树按策略重启。
API 必须说清策略。将所有场景硬套 fail-fast,会让批量任务丢失有价值结果;完全不传播失败则制造幽灵任务。
deadline、timeout 与取消¶
deadline 是绝对时刻,timeout 是从现在起的时长。跨服务传播 deadline 能避免每层重置完整 timeout:
取消通常是协作式信号。每个阻塞点/循环需:
- 检查取消;
- 取消或等待在途 I/O;
- 保证资源释放;
- 定义不可撤销副作用;
- 把“操作完成”和“取消到达”竞态归并到单一结果。
超时返回不是底层工作已经停止的证明。
Python 3.11+ TaskGroup¶
下面的标准库示例展示父任务 timeout、子任务作用域与失败传播。任一 child 抛出普通异常时,TaskGroup 会取消其余 child,退出时抛出异常组。
import asyncio
async def step(name: str, delay: float, fail: bool = False) -> str:
try:
await asyncio.sleep(delay)
if fail:
raise RuntimeError(f"{name} failed")
return name
finally:
print(f"{name}: cleanup")
async def request() -> list[str]:
async with asyncio.timeout(2.0):
async with asyncio.TaskGroup() as group:
tasks = [
group.create_task(step("profile", 0.2)),
group.create_task(step("inventory", 0.4)),
group.create_task(step("policy", 0.3)),
]
return [task.result() for task in tasks]
if __name__ == "__main__":
print(asyncio.run(request()))
若要观察多个异常,可用 except* 处理异常组。不要在 child 中吞掉 CancelledError 而不继续传播,否则结构化取消可能失效。具体规则以当前 Python 文档为准。
nursery/scope 的实现不变量¶
一个抽象 task scope 需要维护:
state: OPEN -> CANCELLING -> JOINING -> CLOSED
children: count / registry
first or aggregate error
cancellation token/source
deadline timer
parent continuation
spawn 只在 OPEN 接受;child 完成原子减少计数;首个失败按策略触发 CANCELLING;最后一个 child 完成后恢复 parent。关闭与 child completion 可能并发,需要防 double resume 和丢失唤醒。
资源作用域¶
任务树和资源树应对齐:
若先关闭 pool 再 join children,就会发生 use-after-close;若 child detach,就无法确定何时安全关闭。
RAII、defer、context manager 和 try/finally 都可以表达资源作用域,但只能在任务生命周期也被收束时完整生效。
背压与 fan-out¶
结构化并发不自动限制 child 数量。对 \(n\) 个输入直接 spawn \(n\) 个任务,仍可能耗尽连接、fd、内存或下游 quota。
用 semaphore、有界 channel 或固定 worker group 限制并发:
并发上限应由瓶颈和 SLO 测量,而不是固定成“CPU 核数”或任意常量。
服务级 detached 工作¶
确实比单请求活得更久的任务,应重新归属:
process/service scope
├── accept loop
├── metrics exporter
├── cache refresher
└── request scopes...
服务关闭时顶层 scope 统一取消并 join。所谓 detached 不再是“无人负责”,只是父作用域更长。
Go、Kotlin、Swift、C++ 的边界¶
- Go
context.Context传播 deadline/cancel,但裸go语句不强制 join;需要errgroup或显式监督协议。 - Kotlin coroutine scope 与 structured concurrency 紧密结合,
supervisorScope提供不同失败策略。 - Swift task group 和 child task 形成结构化作用域,unstructured task 有单独语义。
- C++ coroutine 语言本身不提供结构化 scheduler;
std::jthread/stop token 和 execution proposals 提供不同拼图,具体可用性取决于标准/库版本。
不要因为语法都叫 async/await 就假设失败、取消和 child 生命周期相同。
测量¶
- active child 数和 fan-out 分布;
- scope 完成/取消延迟;
- deadline budget 在各层消耗;
- 取消后仍在运行的任务;
- 异常组大小与首因;
- semaphore/channel 排队;
- shutdown join 时间;
- orphan/detached task 数,应接近显式服务任务集合。
用 trace ID + task ID + parent ID 重建任务树,比只记录线程 ID 更适合 coroutine scheduler。
失败模式¶
- task handle 离开作用域却未 join;
- timeout 只取消 wrapper,底层 I/O 继续;
- child 吞掉取消异常;
- fan-out 无界;
- fail-fast 与 collect-all 语义混淆;
- cleanup 启动新的无限期工作;
- child 持有父栈引用却被 detach;
- shutdown 只发取消,不等待资源真正释放;
- deadline 每层重置,整体远超客户端预算。
跨层连接¶
- coroutine frame 与 scheduler 见协程;
- I/O completion 与取消竞态见异步 I/O 与事件循环;
- 队列与活性见并发基础;
- stop/join 最终落到线程与操作系统调度。