开篇
Swift 5.5 引入 async/await 时,很多人以为「异步」就是加个 await。真正上手才发现,Swift 的并发模型是围绕两件事设计的:结构化并发保证任务不会失控地泄漏,隔离域保证共享状态不会出现数据竞争。这两件事和 Swift 并发 Combine 与 async/await
里讲的 Combine 桥接处在不同层面——那篇解决「怎么写异步代码」,本篇解决「运行时怎么调度、编译器怎么检查」。
工程上最痛的三个问题都很具体:Task 起了之后不知道怎么取消,cancel() 发出去了任务却还在跑;actor 用了但界面还是崩在后台线程;升级 Swift 6 后满屏 Sendable 报错不知道怎么改。这些都不是语法问题,而是对隔离模型的误解。
本文按「任务树 → Task 形态 → 并发组合 → 取消 → Actor 隔离 → Sendable → MainActor → 背压 → 检测工具」的顺序展开,基于 Swift 5.9/6 与 iOS 17/18。结论先给:结构化优先于非结构化,隔离域优先于锁,检查点优先于强制终止。
一、结构化并发的三条规则
结构化并发(structured concurrency)不是一个 API,而是一组约束。它借用结构化编程里「if/for 有明确入口出口」的思想,给并发任务也划定明确的生命周期边界。
规则一:子任务的生命周期不超出父任务。用 withTaskGroup 或 async let 创建的任务,在父作用域结束时必然已经完成或已取消,不存在「游离在外的后台任务」。
规则二:取消沿任务树向下传播。父任务取消时,所有子任务被标记为取消,不需要手工逐个通知。
规则三:错误沿任务树向上冒泡。withThrowingTaskGroup 中任一子任务抛错,兄弟任务被取消,错误在 withThrowingTaskGroup 的调用点抛出。
func loadDashboard() async throws -> Dashboard {
try await withThrowingTaskGroup(of: Section.self) { group in
group.addTask { try await api.fetchProfile() }
group.addTask { try await api.fetchFeed() }
group.addTask { try await api.fetchBadges() }
var sections: [Section] = []
for try await section in group { // 任一失败 → 其余取消 → 此处抛出
sections.append(section)
}
return Dashboard(sections: sections)
}
}
这三条规则带来的直接收益是可推理:看到一个 withTaskGroup 块,就知道它结束之后没有残留任务;看到一个 throws 函数,就知道错误不会被吞在某个角落。相比之下,Task { } 创建的是非结构化任务,它不受这三条规则约束,因此要慎用。
二、Task 的形态与生命周期
Task 是并发的入口,但它的三种形态差异很大,选错了会引入难以排查的问题。
// 1. 继承当前上下文(actor 隔离、优先级、TaskLocal)
Task { await self.refresh() }
// 2. 指定优先级,仍继承隔离
Task(priority: .background) { await self.syncCache() }
// 3. 完全脱离上下文
Task.detached { await self.syncCache() }
Task.detached 不继承 @MainActor、不继承优先级、不继承 TaskLocal 值,也不继承父任务的取消。它在 Swift 6 下几乎必然编译报错:闭包里访问 MainActor 隔离的属性需要 await,捕获非 Sendable 的 self 直接被拒。结论是——除非明确要脱离上下文,否则不要用 detached。
一个常被忽略的事实:Task { } 虽然继承隔离,但它不继承取消。父任务取消时,Task { } 起的子任务不会被自动取消,因为它挂在当前任务树之外。要获得取消传播,必须用 async let 或 TaskGroup。
// 反例:视图消失后任务仍在跑
func onAppear() {
Task { await viewModel.load() } // 不会随视图消失取消
}
// 正例:用 .task 修饰符,生命周期绑定视图
.task { await viewModel.load() }
Task 句柄本身是 Sendable 的,可以跨隔离域传递,因此常被存进属性以便后续取消:
@MainActor
final class SearchController {
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
searchTask?.cancel() // 取消上一次
searchTask = Task { await perform(query) }
}
}
三、async let 与 TaskGroup 的分工
async let 和 TaskGroup 都提供结构化并发,但适用场景不同。
async let 适合数量已知、类型各异的并发:
async let profile = api.fetchProfile() // Profile
async let feed = api.fetchFeed() // [Post]
async let count = api.fetchUnreadCount() // Int
let dashboard = try await Dashboard(profile: profile, feed: feed, unread: count)
async let 的语义是「并发启动、顺序等待」:三条请求在声明处就并发发出,try await 时统一收口。任何一个抛出,其余被取消。
TaskGroup 适合数量动态、类型相同的并发:
func downloadAll(_ urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await URLSession.shared.data(from: url).0 }
}
var results: [Data] = []
for try await data in group { results.append(data) }
return results
}
}
关键区别在于结果顺序。TaskGroup 的 for try await 按完成顺序返回,不是提交顺序。要保证顺序必须显式带索引:
group.addTask { (index, try await work(items[index])) }
// 收集后按 index 排序
另一个高频需求是限制并发度。TaskGroup 默认会把所有任务一次性提交,网络场景下可能触发限流。正确做法是用一个「滑动窗口」:
func mapLimit<T, R>(_ items: [T], limit: Int,
_ work: @escaping (T) async throws -> R) async throws -> [R] {
try await withThrowingTaskGroup(of: R.self) { group in
var iterator = items.makeIterator()
var results: [R] = []
for _ in 0..<min(limit, items.count) {
if let item = iterator.next() { group.addTask { try await work(item) } }
}
while let result = try await group.next() {
results.append(result)
if let item = iterator.next() { group.addTask { try await work(item) } }
}
return results
}
}
这段「先填满窗口、每收一个补一个」的模式是 TaskGroup 最实用的用法,比 DispatchSemaphore 干净得多。
四、取消是协作式的
Swift 的取消是协作式的:task.cancel() 只是把 isCancelled 置为 true,并把信号传播给子任务,不会抢占式终止正在执行的代码。
标准库的异步 API 都会自动检查取消:Task.sleep 抛 CancellationError、URLSession 的 async 方法抛 URLError.cancelled、AsyncSequence 的迭代器在取消时结束。自己写的循环必须手动插检查点:
func process(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation() // 取消则抛 CancellationError
await handle(item)
}
}
// 不抛错的版本
func processQuietly(_ items: [Item]) async {
for item in items {
guard !Task.isCancelled else { return }
await handle(item)
}
}
一个容易踩的坑:检查点的位置决定了响应延迟。如果一次 await handle(item) 要跑 10 秒,那么取消信号最多延迟 10 秒生效。长耗时的同步计算里要主动插检查:
for chunk in data.chunked(into: 1000) {
try Task.checkCancellation()
process(chunk)
}
还有两个边界情况。第一,取消后抛出的 CancellationError 往往不该当成「错误」上报给用户——搜索被新输入取代是正常流程:
do { try await search(query) }
catch is CancellationError { /* 静默忽略 */ }
catch { report(error) }
第二,需要在取消时做资源清理,用 withTaskCancellationHandler:
await withTaskCancellationHandler {
await longRunningWork()
} onCancel: {
connection.close() // onCancel 可能在任意线程同步执行
}
onCancel 里的代码必须是线程安全且非阻塞的,它可能在任务运行的任意时刻被调用。
五、Actor 隔离与可重入
actor 是 Swift 提供的隔离单元:actor 内部的可变状态同一时刻只允许一个任务访问,由运行时用队列串行化,而不是靠开发者加锁。
actor ImageCache {
private var storage: [URL: Data] = [:]
private var inFlight: [URL: Task<Data, Error>] = [:]
func data(for url: URL) async throws -> Data {
if let cached = storage[url] { return cached }
if let task = inFlight[url] { return try await task.value }
let task = Task { try await download(url) }
inFlight[url] = task
defer { inFlight[url] = nil }
let data = try await task.value
storage[url] = data
return data
}
}
这段代码展示了 actor 最常见的两个用途:串行化共享状态(storage)和请求合并(inFlight 去重)。
必须理解 actor 的可重入(reentrancy):当 actor 方法执行到 await 挂起时,actor 会释放执行权,允许其他任务进入执行其他方法。也就是说,await 前后 actor 的内部状态可能已经被改变。
actor Counter {
private var value = 0
func incrementAndRead() async -> Int {
value += 1 // 读到 1
await someAsyncWork() // 挂起!其他任务可以进入
return value // 可能是 5,不是 1
}
}
这是 Swift actor 与「串行队列」最大的认知差异:actor 保证方法不会并行执行,但不保证方法内部状态在 await 前后一致。凡是跨 await 依赖的状态,必须在挂起前快照到局部变量:
func incrementAndRead() async -> Int {
value += 1
let snapshot = value
await someAsyncWork()
return snapshot
}
需要在多个 await 之间保持原子性的操作,要么合并成一个不带 await 的同步方法,要么显式引入状态机。
六、Sendable 与隔离域检查
Sendable 是 Swift 6 严格并发的核心协议:能在隔离域之间安全传递的类型。它没有运行时语义,纯粹是编译期契约。
自动满足 Sendable 的情况:
- 值类型(
struct/enum)且所有存储属性都是Sendable。 final类且所有存储属性是let且Sendable。- actor(内部状态天然隔离)。
- 标注了
@MainActor的类型。
需要显式处理的情况:
// 1. 明确安全:显式声明
struct User: Sendable { let id: Int; let name: String }
// 2. 内部有锁保护:向编译器承诺
final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
func bump() { lock.lock(); defer { lock.unlock() }; value += 1 }
}
// 3. 引用类型且可变:用 actor 替代
actor SafeCounter { private var value = 0; func bump() { value += 1 } }
@unchecked Sendable 是你对编译器的承诺,一旦内部实现不满足线程安全,编译器不再兜底,数据竞争会退化成运行时随机崩溃。能用 actor 就不要用 @unchecked。
@unchecked Sendable 最常见的误用是给一个「只是目前没被并发访问」的类打上标记。判断标准很硬:这个类的所有可变状态是否有明确的同步机制保护?没有就别标。
非 Sendable 类型跨隔离域传递时的典型报错与解法:
| 报错 | 成因 | 解法 |
|---|---|---|
Capture of non-Sendable type | 闭包跨域捕获了可变引用类型 | 改值类型,或标 @MainActor |
Non-sendable type cannot cross actor boundary | 把非 Sendable 对象传进 actor 方法 | 传值快照,或让类型 Sendable |
Static property is not concurrency-safe | 全局可变状态 | 加 @MainActor 或改 let |
七、@MainActor 与 UI 更新
@MainActor 是一个全局 actor,代表主线程执行上下文。它的作用是把「必须在主线程执行」这件事从运行时的 DispatchQueue.main.async 变成编译期的类型约束。
@MainActor
final class FeedViewModel {
private(set) var posts: [Post] = []
func refresh() async {
let fetched = await api.fetchFeed() // 挂起期间不阻塞主线程
posts = fetched // 编译器保证回到主线程
}
}
关键认识:await 的挂起点不阻塞线程。await api.fetchFeed() 执行时会把控制权交还给系统,主线程可以去处理别的 UI 事件,等结果回来再恢复执行。所以 @MainActor 上的长耗时同步计算依然会卡界面:
@MainActor
func render() {
let image = heavyDecode(data) // 同步阻塞主线程,界面卡死
}
正确做法是把重计算移出 MainActor:
nonisolated func decode(_ data: Data) async -> UIImage? {
await Task.detached(priority: .userInitiated) { decodeImage(data) }.value
}
反过来,从后台任务更新 UI 时,@MainActor 会让编译器替你插入跳转:
Task.detached {
let data = try await fetch()
await MainActor.run { self.items = data } // 显式回到主线程
}
MainActor.run 与「调用一个 @MainActor 方法」等价,前者适合一次性赋值,后者适合成组的 UI 操作。
还有一个实用技巧:SwiftUI 的 View 协议本身已经隐含 @MainActor,所以 body 里调用 MainActor 隔离的方法不需要 await,而 ObservableObject 的 @Published 属性赋值必须在主线程——这就是为什么 ViewModel 通常整体标注 @MainActor。
7.1 nonisolated 与隔离逃逸
nonisolated 把一个成员从 actor 的隔离域里「摘出来」,表示它不访问隔离状态、可在任意上下文同步调用。它最常见的用途是让 actor 满足非隔离的协议要求(如 Hashable):
actor Session {
nonisolated let id: UUID // 不可变,无需隔离
private var token: String?
nonisolated func describe() -> String { "session \(id)" }
func update(token: String) { self.token = token } // 仍受隔离保护
}
nonisolated 成员内部不能访问 actor 的可变状态,要访问就必须 await 一个隔离方法。Swift 5.10/6 还引入了 nonisolated(unsafe) var globalCache: [String: Data],用于标注「全局可变状态但我知道它是安全的」——它是迁移期的逃生舱口,滥用等于放弃编译期保护。
八、AsyncSequence 与背压
AsyncSequence 是异步版序列,for await 遍历。背压(backpressure)是它的核心议题:生产者产出速度快于消费者时怎么办。
AsyncStream 提供三种缓冲策略:
| 策略 | 行为 | 典型场景 |
|---|---|---|
.unbounded | 不丢数据,可能吃光内存 | 一条都不能丢的上报 |
.bufferingNewest(n) | 保留最新 n 个,丢最旧 | 位置、传感器最新值 |
.bufferingOldest(n) | 保留最旧 n 个,丢最新 | 需要按序消费的前 N 条 |
选择依据是语义:位置更新这类「只关心最新」的流用 bufferingNewest(1);日志上报这类「一条都不能丢」的流用 unbounded 但要配合限流。
func locationStream() -> AsyncStream<CLLocation> {
AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
let manager = CLLocationManager()
let delegate = LocationDelegate { continuation.yield($0) }
manager.delegate = delegate
manager.startUpdatingLocation()
continuation.onTermination = { _ in
manager.stopUpdatingLocation() // 消费者取消时清理
_ = delegate // 保持引用
}
}
}
onTermination 是资源清理的唯一正确位置,它对应 Combine 里 AnyCancellable.cancel() 的语义。忘了它就会出现「界面已销毁但定位仍在跑」。
背压的另一种实现是用 continuation 的 yield 结果反向限流:yield 返回 YieldResult,.dropped 表示被丢弃,生产者据此降速。但 AsyncStream 不提供「消费者就绪」的显式信号,需要严格背压时应改用 AsyncChannel 这类第三方实现或自建信号量。
九、数据竞争检测工具链
并发 bug 的特点是不可复现,所以工具比调试技巧重要。
第一层是编译器。开启 -strict-concurrency=complete 后,绝大多数数据竞争在编译期就被拦下:
Xcode: Build Settings → Swift Compiler - Upcoming Features
命令行: swift build -Xswiftc -strict-concurrency=complete
第二层是 Thread Sanitizer(TSan)。它通过插桩记录每次内存访问,能捕获运行期真正发生的数据竞争,报出两条冲突访问的完整堆栈:
xcodebuild test -scheme MyApp -enableThreadSanitizer YES
TSan 的局限是只报告实际发生的竞争,测试没覆盖到的路径它看不见,且有 5-15 倍性能开销,不适合长期挂在 CI 上跑全量。
第三层是 Swift 6 语言模式。它把严格并发检查从「警告」升级为「错误」,是迁移的终点站。在 Package.swift 里可以按 target 逐级开启(swiftSettings: [.swiftLanguageMode(.v6)]),不必一次性全量切换。
实践中的迁移顺序是:先开 complete 消警告 → 修 Sendable → 修 actor 隔离 → 最后切语言模式。具体的剖析与卡顿定位手段,可以配合 iOS 性能调优与 Instruments 剖析
里的 TSan/Leaks 流程一起用。
权衡取舍
| 方案 | 生命周期 | 取消传播 | 适用场景 |
|---|---|---|---|
async let | 结构化 | 自动 | 数量固定的并发请求 |
TaskGroup | 结构化 | 自动 | 数量动态的批量任务 |
Task { } | 非结构化 | 不继承 | 从同步上下文启动、需手动管理 |
Task.detached | 非结构化 | 不继承 | 明确要脱离上下文的场景 |
DispatchQueue | 非结构化 | 无 | 遗留代码、精确 QoS 控制 |
隔离手段的选择:
- actor:共享可变状态的首选,语义清晰、编译期保护。
@MainActor:UI 相关状态与 ViewModel 的默认归属。@unchecked Sendable+ 锁:仅在需要同步、低开销访问时使用,且必须有测试覆盖。nonisolated(unsafe):迁移期临时手段,不应长期保留。
并发度的选择:网络请求建议 4-6 并发,CPU 密集任务建议不超过 ProcessInfo.processInfo.activeProcessorCount,否则上下文切换的开销会盖过并行收益。
常见坑清单
Task { }不随父任务取消:以为起了 Task 就有取消传播,实际要用async let/TaskGroup。- actor 可重入导致状态不一致:跨
await依赖 actor 内部状态,挂起期间被其他任务改写;应在挂起前快照。 @MainActor上的同步重计算:await不阻塞线程,但同步代码会;重计算要nonisolated+ 后台执行。TaskGroup结果顺序错乱:for try await按完成顺序返回,需要顺序时显式带索引再排序。TaskGroup并发度失控:一次性提交上千个任务触发限流或内存暴涨;用滑动窗口限流。- 取消被当成错误上报:搜索被新输入取代是正常流程,应捕获
CancellationError静默忽略。 onTermination忘记清理:AsyncStream 消费者取消后底层资源仍在运行,定位/定时器不停。@unchecked Sendable滥用:给没有同步保护的类打标记,编译期保护失效,运行时随机崩溃。Task.detached里更新 UI:不继承@MainActor,Swift 6 下直接编译报错,且丢失TaskLocal。- 用 TSan 当唯一防线:TSan 只报实际发生的竞争,测试覆盖不到就查不出;必须配合编译器严格检查。
相关阅读
- Swift 并发 Combine 与 async/await — async/await 基础语法与 Combine 桥接
- Swift 协议与泛型编程
—
Sendable、some/any与泛型约束的关系 - Swift 内存管理与 ARC 循环引用
— 任务闭包捕获
self时的引用循环 - Kotlin 协程与并发 — 对照看结构化并发在不同语言里的表达
小结
Swift 并发的心智模型可以压缩成两句话:结构化并发管生命周期,隔离域管数据安全。async let 与 TaskGroup 提供前者的保障,actor 与 @MainActor 提供后者的保障,Sendable 是两者的连接件。理解了这三者的分工,绝大多数「任务不取消」「状态串味」「Swift 6 编译不过」的问题都能定位到具体环节。
工程落地的优先级是:能用结构化就别用 Task { };能用 actor 就别用锁;能靠编译器检查就别靠 Code Review;取消是协作式的,检查点要主动埋。Swift 6 的严格并发短期看是迁移负担,长期看把一整类「偶发崩溃」提前到了编译期,是净收益。下一步建议结合具体的网络与持久化场景,把并发模型用到真实的请求合并与缓存策略上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。