Swift 与 Objective-C

[Swift进阶 #4] 结构化并发:为什么不能随意创建Task

结构化并发回答了谁负责异步工作的结束。本文整理子任务无法离开父作用域的原理、取消信号的传播方式,以及使用Task.detached时需要手动补回的保证。

6 分钟阅读
[Swift进阶 #4] 结构化并发:为什么不能随意创建Task 封面图

Concurrency系列还剩最后一个问题。到目前为止,我们看过async函数(第1篇)、actor(第2篇)和Sendable(第3篇)。

本文接续上一篇文章 Swift进阶 #3

我们还没有真正处理进入异步世界的入口Task。而一旦处理Task,这个系列中最实用的概念就会出现。

结构化并发。

名字听起来很宏大,但问题很简单:启动异步工作后,谁负责它的结束?

“结构化”的含义 — 工作也有作用域

这个名字来自结构化编程这一旧术语。经历了goto可以把控制流跳到任意位置的时代后,if和for等代码块结构开始保证“控制流会返回进入的位置”。

结构化并发将同一原则应用于异步工作。子任务不能离开父作用域,父任务必须等所有子任务完成后才能结束。

第1篇介绍的async let,其实就是这一原则的第一个例子。

func loadDashboard() async throws -> Dashboard {
    async let profile = fetchProfile()    // 子任务 1
    async let feed = fetchFeed()          // 子任务 2
    return try await Dashboard(profile: profile, feed: feed)
}   // 这个函数在两个子任务完成清理前无法返回

使用async let创建的子任务必须在函数返回前完成或被取消。发生异常时也一样。

如果feed抛出错误,仍在运行的profile会自动收到取消信号。函数完成清理后才会传播错误。

由于工作绑定在作用域中,因此结构上不可能存在“启动后就忘掉的任务”。

在闭包篇中,escaping是“生命周期比函数更长的闭包”的警告标签。结构化并发则从默认模型中彻底移除了“生命周期比函数更长的工作”。

如果子任务数量是动态决定的,就使用TaskGroup。并行获取URL列表的典型代码如下。

let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
    for url in urls {
        group.addTask { (url, try await download(url)) }
    }
    var result: [URL: Image] = [:]
    for try await (url, image) in group {
        result[url] = image
    }
    return result
}

有两点值得注意。结果按完成顺序到达(如果需要保证顺序,就像上面一样和键一起保存),并且闭包结束时,组中的所有子任务都会完成清理。

如果说async let是“几个子任务”的语法,那么TaskGroup就是“n个子任务”的语法,作用域保证相同。

取消 — 信号会到,但停止是你的责任

结构化并发的第二个支柱是取消传播。父任务被取消后,取消信号会沿树向下传给所有后代。

离开页面时,该页面发起的网络请求会连锁取消,正是得益于这种结构。

不过Swift的取消是协作式(cooperative)的。取消信号不会强制杀死任务。

它只会设置isCancelled标志;检查标志并停止,是任务自己的责任。

func processLargeFile() async throws {
    for chunk in chunks {
        try Task.checkCancellation()   // 如果已取消,就抛出 CancellationError
        await process(chunk)
    }
}

为什么不是强制终止?因为任务如果在文件写到一半、持有锁,或处于事务中途时突然死亡,系统可能会留下损坏状态。

协作式取消的逻辑是:“只有任务自己知道在哪里停止最合适。”

实际含义很明确:长时间运行的循环必须加入checkCancellation。

URLSession等系统API已经在内部遵守取消,因此可以直接信任它们。

相反,忽略取消的长时间计算代码即使被取消也不会停止。不是取消失效,而是没有进行检查。

展示从父任务向子任务传递的取消信号和检查清单的示意图
取消信号会沿树向下传递,但只有进行检查的任务才会停止

Task与Task.detached — 非结构化世界及其代价

如果到这里都是结构化世界,那么Task { }就在其外部。使用Task创建的任务不属于父子树,是非结构化(unstructured)任务。

它不受作用域约束,错误和取消也不会自动传播。

那它为什么存在?因为我们需要一座从同步世界通往异步世界的桥。

按钮点击处理程序是同步函数,无法使用await,因此Task { await viewModel.refresh() }会打开新的异步上下文。这就是Task合理的使用场景。

SwiftUI的.task修饰符在这座桥上加入了生命周期管理(视图消失时自动取消),所以UI中应优先使用它。

问题在于Task变成习惯。在async函数中再次创建Task,或以fire-and-forget方式丢出的Task,都是例子。结构提供的保证(等待完成、错误传播、取消链)全都需要手动补回。

你必须保存引用、手动调用cancel,也要手动记录错误。没有这些管理代码,就会出现悄悄消失的错误和僵尸任务。

归纳成规则:在async上下文中,默认使用async let和TaskGroup;Task只用于同步→异步边界。

Task.detached更进一步脱离结构。它不继承优先级、actor隔离或task-local值,是完全孤立的任务(SE-0304)。

人们常因“想离开@MainActor上下文执行繁重工作”而使用它,但大多数情况下这是错误的做法。将函数声明为nonisolated async,它就会自动在协作线程池中运行(第1篇)。

真正需要detached的情况很少,例如必须刻意独立于当前上下文的后台工作。借用官方文档的说法,它是最后手段(last resort)。

系列总结 — 四个概念构成的一幅图

在结束Concurrency系列时,让我们一次性整理完整全貌。

async/await让异步流程重新回到编译器的视野中(第1篇)。actor为共享可变状态内置了串行保护(第2篇),Sendable则检查跨越隔离边界的值是否安全(第3篇)。

而结构化并发将任务生命周期绑定到作用域。开始的工作必须结束,取消会沿树流动(本篇)。

贯穿四者的一句话是:把并发的隐式规则变成语言的显式结构。

线程管理规则变成了协作线程池和挂起,锁规则变成了actor。“这个对象能否传到其他线程”的口头知识变成了Sendable,“别忘了清理请求”的评审意见变成了任务树。

Swift哲学系列第1篇中的安全优先原则,已经扩展到并发这一最困难的领域。这就是Swift Concurrency的完整故事。

对比围栏内的结构化任务与漂浮在外的非结构化Task的插图
结构外的Task必须手动补回完成、错误和取消保证

总结

  • 结构化并发的原则是“子任务不能离开父作用域”。async let(固定数量)和TaskGroup(动态数量)是对应语法,出错时兄弟任务取消和清理都会自动完成。
  • 取消是协作式的。信号会沿树传播,但会停止的是检查checkCancellation的任务本身。
  • Task { }只用于同步→异步桥接。在async上下文中习惯性使用Task,会把结构保证变成手动管理负债。Task.detached是最后手段。
  • 整个系列的要点:线程、锁和生命周期管理的隐式规则,已经转移为挂起、actor、Sendable和任务树等语言结构。

从下一篇开始,我们将深入性能:final为什么是性能关键字、协议调用在哪里变慢,以及静态分派和动态分派的真实机制。

延伸阅读

来源与验证

  • SE-0304: Structured ConcurrencySwift Evolution · 标准或规范 · 核查 2026年8月17日依据: Task与TaskGroup的父子结构;取消、优先级、task-local和actor上下文继承