Swift 结构化并发与 Actor 隔离

从调度与隔离模型切入 Swift 并发,讲清结构化并发的任务树与 async let、TaskGroup 的并发语义、取消的协作式传播与检查点、Actor 隔离与可重入陷阱、Sendable 检查与 @MainActor 的 UI 更新约束、AsyncStream 背压策略,并给出数据竞争检测与 Swift 6 严格并发的迁移清单。

开篇

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 并发的心智模型可以压缩成两句话:结构化并发管生命周期,隔离域管数据安全。async let 与 TaskGroup 提供前者的保障,actor 与 @MainActor 提供后者的保障,Sendable 是两者的连接件。理解了这三者的分工,绝大多数「任务不取消」「状态串味」「Swift 6 编译不过」的问题都能定位到具体环节。

工程落地的优先级是:能用结构化就别用 Task { };能用 actor 就别用锁;能靠编译器检查就别靠 Code Review;取消是协作式的,检查点要主动埋。Swift 6 的严格并发短期看是迁移负担,长期看把一整类「偶发崩溃」提前到了编译期,是净收益。下一步建议结合具体的网络与持久化场景,把并发模型用到真实的请求合并与缓存策略上。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全