Skip to content

Swift 结构化并发取消树工程实战

6 min read

Swift 并发最容易被低估的能力,不是 async/await,而是结构化并发里的任务树语义。很多项目把 Task {} 当成新一代 GCD 用法,代码短期跑得动,长期却出现任务泄漏、重复请求、页面退出后后台仍在烧 CPU。根因就是没有把“父子任务生命周期绑定”当成工程约束。

这篇文章只解决一个目标:让取消信号可以稳定向下传播,让任务在业务结束时被完整回收,而不是留下僵尸任务继续消耗资源。

一、任务树到底在保护什么

结构化并发并不是语法糖,它在做三件关键事情:

  1. 父任务等待子任务完成,生命周期可推理。
  2. 父任务取消时,子任务收到取消信号。
  3. 错误沿树向上传播,避免静默失败。

这三条让并发逻辑从“到处飞的异步回调”变成“可追踪的树状执行图”。

如果开发者绕开这套机制,直接到处 Task.detached,就等于主动放弃可管理性。你会得到短期灵活,代价是长期无法证明谁该停止、何时停止、是否已停止。

二、先看故障图:取消没传到下游会发生什么

flowchart TD
    A[用户进入详情页] --> B[父任务 loadDetail 启动]
    B --> C[子任务: 基本信息]
    B --> D[子任务: 评论列表]
    B --> E[子任务: 推荐流]
    A --> F[用户快速返回上一页]
    F --> G[父任务被取消]
    G --> H{子任务是否响应取消}
    H -- 否 --> I[网络请求继续 + 解码继续]
    I --> J[CPU占用上升, 内存抖动, 无效日志]
    H -- 是 --> K[快速终止并释放资源]

这个问题在滚动列表和搜索建议场景尤其明显:用户操作变化快,如果取消信号不及时,系统会一直做“过期工作”。

三、架构方案:把取消当一等公民

1)业务入口统一用结构化任务

推荐所有页面级、流程级并发入口都放在受控父任务里,例如 withThrowingTaskGroupasync let。这样取消动作只需针对父任务触发,子任务自动收敛。

func loadDetail(id: String) async throws -> DetailAggregate {
    try await withThrowingTaskGroup(of: Partial.self) { group in
        group.addTask { .profile(try await fetchProfile(id)) }
        group.addTask { .comments(try await fetchComments(id)) }
        group.addTask { .recommend(try await fetchRecommend(id)) }

        var aggregate = DetailAggregate.empty
        for try await part in group {
            aggregate.merge(part)
        }
        return aggregate
    }
}

2)明确禁止“无主任务”扩散

定义团队约束:

  1. 业务代码默认禁止 Task.detached
  2. 若必须 detached,必须写明归属者和终止策略。
  3. detached 任务要有 timeout 与监控字段。

3)取消检查写在“贵操作前”

Task.checkCancellation() 不该只在函数开头调用一次。正确做法是在每个高成本阶段前检查:网络请求前、解码前、写盘前、批量计算前。

4)资源回收与取消绑定

取消不仅是“返回错误”,还必须触发清理动作:关闭流、释放缓存句柄、停止重试计时器、撤销 UI 更新队列。

四、取消树设计中的 6 个常见误区

  1. 误把取消当异常:取消是业务正常路径之一,不是稀有故障。
  2. 只取消父任务,不检查子任务是否真停。
  3. 把重试逻辑写在子任务内部,导致取消后又自己拉起。
  4. 网络层不透传取消,URLSession 仍在跑。
  5. 把取消吞掉不记录,导致排障无证据。
  6. MainActor 上做清理重活,造成“取消时反而更卡”。

这些误区不改,结构化并发就会被写成“看起来结构化,实际上半结构化”。

五、并发编排范式:分层任务树

建议把任务树拆成三层,每层职责固定:

  1. 页面层任务:绑定用户可见生命周期。
  2. 用例层任务:并行编排数据源。
  3. 基础设施层任务:网络、缓存、存储。

层间规则:

  • 上层可取消下层。
  • 下层不能反向持有上层生命周期。
  • 任何层都不得静默创建无主长任务。

这样设计后,页面退出时只需取消最上层任务,系统就能自然回收整个链路。

六、调优策略:取消不仅稳,还要快

取消响应慢,等于没取消。可执行的优化策略:

  1. 子任务拆粒度要合适,太大块会导致取消检查点稀疏。
  2. 避免在单个子任务内串太多 I/O;分阶段后可更快中断。
  3. 对高频用户交互场景设置更严格 timeout。
  4. 用 signpost 标记“取消发出时间”和“任务终止时间”,追踪取消尾延迟。

建议指标:

  • 取消到子任务完全终止 P95 < 80ms。
  • 页面离开后 1s 内无遗留业务任务。
  • 取消后无继续写 UI 或写缓存的副作用。

七、测试方法:如何验证取消树真的有效

1)确定性取消测试

构造可控时钟和可控网络桩,确保每次测试都在同一时刻触发取消,验证结果一致。

2)级联取消测试

父任务取消后检查:

  1. 子任务都收到取消。
  2. 子任务的下游 I/O 停止。
  3. 清理动作执行完成。

3)竞争条件测试

在“子任务即将完成”与“父任务刚取消”边界反复压测,确保不会出现重复回调或状态污染。

4)恢复测试

取消后重新进入页面,验证新任务不会读到旧任务残留结果。

八、线上排障流程:先确认传播,再确认回收

sequenceDiagram
    participant U as UserAction
    participant P as ParentTask
    participant C1 as ChildTask-A
    participant C2 as ChildTask-B
    participant N as Network

    U->>P: 页面退出 / 筛选条件变化
    P->>C1: cancel
    P->>C2: cancel
    C1->>N: 终止请求
    C2->>N: 终止请求
    C1-->>P: cancelled
    C2-->>P: cancelled
    P-->>U: 清理完成

排障时优先核查四件事:

  1. 取消信号发出了吗。
  2. 子任务收到了吗。
  3. 下游 I/O 停了吗。
  4. 清理动作做了吗。

四步里任何一步断掉,都会表现为“取消了但系统还在忙”。

九、与 UI 协作:@MainActor 只做最小提交

并发取消常与 UI 逻辑耦合。建议模式:

  1. 数据加载在后台任务树。
  2. 结果归并后一次性切回 @MainActor 提交。
  3. 若任务已取消,禁止提交 UI。
@MainActor
func render(_ state: ViewState) {
    guard !state.isStale else { return }
    self.state = state
}

这样能避免“旧任务晚到覆盖新页面”的视觉闪烁问题。

十、CI 门禁建议

把取消治理变成自动化质量门禁:

  1. 关键流程必须有取消用例。
  2. PR 中新增 Task.detached 触发审查提醒。
  3. 每晚跑长时并发稳定性测试,输出遗留任务统计。
  4. 出现取消延迟回归立即阻断发布。

CI 不做这件事,规则只能靠记忆,必然衰减。

十一、落地清单

  1. 盘点现有异步入口,找出无主任务。
  2. 统一替换为结构化父任务。
  3. 在关键阶段增加取消检查点。
  4. 补齐取消后资源回收。
  5. 建立取消延迟和遗留任务监控。
  6. 将测试和门禁并入发布流程。

十二、结语

结构化并发的真正收益,不是代码更“现代”,而是任务生命周期从不可控变成可证明。取消树做对了,系统会更省电、更稳定、更可维护;做错了,所有异步优化最终都在为僵尸任务买单。

把取消当一等公民,是 Swift 并发工程从“能跑”走向“能长期维护”的分水岭。