Swift 与 Objective-C

[Swift 深入 #2] Swift actor 完整解析:如何防止数据竞争

actor 是 Swift 的第四种类型,负责保护自身状态。本篇讲解如何将并发访问排队,让编译器发现数据竞争,以及最著名的陷阱:可重入性。

5 分钟阅读
[Swift 深入 #2] Swift actor 完整解析:如何防止数据竞争 封面图

Swift Concurrency 系列第二篇的主角是 actor。语言新增了第四种类型,既不是 class,也不是 struct。

需要新增类型,说明存在严重问题。我们先从数据竞争开始准确理解。

数据竞争——最棘手的 bug 条件

数据竞争的条件很明确:两个或更多线程同时访问同一块内存,且至少有一个访问会写入。

满足条件时,结果属于未定义行为。值可能损坏、程序可能崩溃,也可能看起来什么都没发生。

具体结果取决于当天的调度运气。

final class Counter {
    var value = 0
    func increment() { value += 1 }  // 读取-相加-写入, 3步骤
}
// 两个线程同时调用 increment()时
// 1000即使调用 value也可能不是 1000

value += 1不是原子的。在读取、相加、写入三步之间插入另一个线程,增量就会消失。

这个 bug 棘手,是因为难以复现。只有时机恰好才会触发,因此测试通过后,发布才收到偶发崩溃报告。

值类型篇提到,值类型不共享,因此数据竞争的前提消失。剩下的是以共享为目的的引用类型。

传统方案是锁、信号量和串行 DispatchQueue。

它们都能工作,但有共同弱点:完全依赖开发者自律。

只要有一处忘记加锁就前功尽弃,编译器也看不到这个错误。

这种模式很熟悉。就像可选值篇的 nil 检查、ARC 篇的循环引用,Swift 会把依赖自律的规则提升到类型系统。

现在轮到数据竞争了。

actor——保护自身状态的类型

一句话说,actor 是内置状态保护的引用类型。

actor Counter {
    var value = 0
    func increment() { value += 1 }
}

只需将 class 改为 actor,就能保证同一时间只有一个执行访问其存储属性。

每个 actor 都有串行执行器,方法调用会排队逐个处理。increment 的三步之间无法插入其他调用,因此不会产生竞争。

关键在于由编译器强制执行。actor 外部访问其状态或方法时,必须添加 await 才能编译。

let counter = Counter()
await counter.increment()          // 外部访问 await 必需
print(await counter.value)

添加 await 的原因与第 1 篇的 suspension 概念相同。actor 忙碌时,请求必须排队,等待期间不会占用线程,而是主动让出执行权。

这不是像锁那样让线程休眠等待。函数会暂停,轮到它时再恢复。

相反,actor 内部代码无需 await 即可自由访问,因为已经处于隔离范围内。

这个“内部还是外部”的边界,就是第 1 篇介绍的隔离(isolation)。

@MainActor 是这个概念的全局版本:将“主线程”变成一个串行上下文的全局 actor,用于隔离 UI 状态。

原理与自定义 actor 相同。

对比并发访问导致水晶破碎与排队后安全访问的示意图
将并发访问排队后,竞争就不会发生

可重入性——actor 最著名的陷阱

actor 看似万能,但设计上有一个陷阱:它允许可重入。

actor 方法遇到 await 时会暂停,actor 变为空闲状态。期间其他调用可以进入并执行。

原方法恢复时,actor 的状态可能已不同于暂停之前。

actor ImageCache {
    var cache: [URL: Image] = [:]

    func image(for url: URL) async -> Image {
        if let cached = cache[url] { return cached }
        let image = await download(url)   // 暂停点——此期间可能有其他调用进入
        cache[url] = image                // 相同的 URL可能已经保存
        return image
    }
}

同一 URL 几乎同时收到两个请求时,两者都会看到缓存未命中并下载。数据不会损坏(actor 会阻止这一点),但逻辑会重复执行。

重要规则是:actor 能阻止底层数据竞争,但不会保证跨越 await 的逻辑一致性。

仍需在 await 前后重新确认状态假设。此例中,将进行中的下载 Task 保存到缓存即可避免重复执行。

为什么这样设计?禁止可重入就必须在 await 期间锁住 actor,两个互相等待的 actor 可能因此永久死锁。

Swift 选择无死锁系统,把逻辑一致性交给开发者。理解这种取舍,就能预测陷阱。

实际应用——actor 适合放在哪里

actor 适合做由多个异步上下文共享的可变状态所有者。

缓存、连接池、下载管理器、会话存储都是例子。凡是需要回答“谁保护这个状态?”的地方,actor 都值得考虑。

不适合的场景也很明确。第一,不共享的状态。

只在一个界面使用的 view model,使用 @MainActor 类更合适。自定义 actor 只会让 UI 访问增加不必要的 await。

第二,不可变数据。既然没有竞争,struct 或 let 就够了,坚持值类型优先。

第三,调用频率极高的热路径。跨越 actor 边界有串行化成本,每秒调用几十万次就该重新审视设计。

再补充一点:只保护一个原子计数器或标志位时,actor 可能过重。

这类场景使用 Swift 6 Synchronization 模块的 Mutex 等底层工具更轻量,也不需要 await。actor 在受保护状态与处理逻辑合为一体时最有价值。

await 期间其他调用进入并改变状态的可重入情境插图
可重入:其他调用可在 await 期间进入并改变状态

总结

  • 数据竞争由“并发访问+至少一次写入”构成未定义行为,且难以复现。锁虽然有效,却依赖自律。
  • actor 是内置状态保护的引用类型。串行执行器让访问排队,编译器强制外部访问使用 await。
  • @MainActor 将主线程变成全局 actor,是 UI 隔离的标准方案。
  • actor 允许可重入。状态可能在 await 前后发生变化,因此除了防止底层竞争,还必须自行维护逻辑一致性。
  • actor 适合做共享可变状态的所有者。对于非共享状态、不可变数据和极端热路径,它可能过重。

下一篇介绍隔离体系的最后一块:Sendable。内容包括可安全跨越隔离边界的类型,以及如何理解 Swift 6 strict concurrency 向现有代码发出的警告。

延伸阅读