Swift actor 与数据隔离
多个任务同时修改同一份数据,就会出现数据竞争:两个任务都读到旧值,各自加一,结果只加了一次。
传统的解决办法是加锁,但锁容易漏、容易死锁,编译器也帮不上忙。
Swift 用 actor(SE-0306,Swift 5.5 引入)把「这份状态只能串行访问」变成类型系统的一部分,由编译器检查。
为什么需要 actor
先看问题本身。下面的类没有任何保护,多个任务并发调用会互相覆盖。
即使只有一个 += 1,它也不是原子操作:读值、加一、写回三步之间可能被打断。
Swift 的并发检查会在编译期指出这类共享可变状态的问题,而 actor 就是给出的解法。
| 做法 | 谁保证安全 | 主要风险 |
|---|---|---|
| 不加保护 | 没人,靠自觉 | 数据竞争,结果不可预测 |
| 手动加锁 | 开发者,靠纪律 | 漏加锁、死锁、锁粒度不当 |
| actor | 编译器检查 | 使用上有 await 的语法负担 |
actor 的定义与隔离状态
actor 的写法和 class 很像,区别在于它的可变状态默认被隔离。
隔离的意思是:外部不能直接读写这些属性,只能通过 actor 自己的方法来访问。
定义一个 actor
下面的计数器把 value 设为私有,只暴露两个方法。
实例
actor Counter {
private var value = 0 // 隔离状态,外部不能直接访问
func increment() {
value += 1 // 在 actor 内部可以自由读写
}
func current() -> Int {
return value
}
}
// 串行访问
let counter = Counter()
await counter.increment()
await counter.increment()
print(await counter.current())
// 并发访问:100 个任务同时加,actor 会排队执行
let shared = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 1...100 {
group.addTask { await shared.increment() }
}
}
print(await shared.current())
运行结果:
2 100
100 个任务并发调用 increment(),结果不多不少正好是 100,因为 actor 保证同一时刻只有一个任务在执行它的隔离代码。
actor 是引用类型,和 class 一样按引用传递。它的初始化器不隔离,所以 Counter() 不需要 await。
用 await 访问隔离成员
从 actor 外部访问它的隔离属性或方法,必须写 await。
这不是可有可无的语法糖,而是告诉你「这里可能排队等待」。
如果目标 actor 正忙,调用会挂起,等它空闲后继续。
| 访问位置 | 是否需要 await | 原因 |
|---|---|---|
| actor 内部访问自己的隔离成员 | 不需要 | 已经在隔离域内 |
| actor 外部访问隔离成员 | 需要 | 要跨隔离域,可能排队 |
| 外部访问 nonisolated 成员 | 不需要 | 不涉及隔离状态 |
| 外部访问 actor 的 let 常量 | 不需要 | 不可变且可安全共享 |
nonisolated
并非所有成员都需要隔离保护。
不访问隔离状态的方法和属性可以标上 nonisolated,这样从任何上下文都能同步调用,不用 await。
实例
actor Bank {
private var balance = 0
nonisolated let name = "RUNOOB 银行"
// 不访问隔离状态,因此可以标为 nonisolated
nonisolated func describe() -> String {
return "这是 \(name) 的账户"
}
func deposit(_ amount: Int) {
balance += amount
}
func currentBalance() -> Int {
return balance
}
}
let bank = Bank()
print(bank.name) // nonisolated,无需 await
print(bank.describe()) // nonisolated,无需 await
await bank.deposit(100)
print(await bank.currentBalance())
运行结果:
RUNOOB 银行 这是 RUNOOB 银行 的账户 100
deposit 和 currentBalance 都碰了 balance,所以必须保持隔离,调用时要 await。
nonisolated 只免除隔离,不提供任何同步保证。标之前先确认这个方法真的没碰共享可变状态,否则等于绕过了编译器给你的保护。
actor 与锁的对比
在 actor 出现之前,保护共享状态的常用手段是锁。
下面用 NSLock 实现同样的计数器,效果和 actor 一样,但安全性完全靠开发者自己保证。
实例
// @unchecked Sendable 表示「我保证它线程安全」,编译器不再检查
final class LockedCounter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
func increment() {
lock.lock()
value += 1
lock.unlock()
}
func current() -> Int {
lock.lock()
defer { lock.unlock() } // 用 defer 保证一定解锁
return value
}
}
let locked = LockedCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 1...100 {
group.addTask { locked.increment() }
}
}
print(locked.current())
运行结果:
100
结果一样,差别在代价和保障。
| 对比项 | 手动加锁 | actor |
|---|---|---|
| 谁来检查 | 开发者,编译器不参与 | 编译器强制隔离 |
| 漏保护的后果 | 悄悄出现数据竞争 | 编译期报错 |
| 死锁风险 | 有,多把锁交叉时尤其危险 | 无传统死锁,但有重入导致的状态问题 |
| 调用方式 | 同步调用 | 跨隔离访问需要 await |
| 能否跨 await 持锁 | 不能,会阻塞整个线程 | 天然不适用,隔离靠串行执行而非持锁 |
NSLock 是「非递归」的,同一线程重复加锁会死锁。用 defer 解锁是最基本的纪律,但漏掉一个提前返回就可能永久锁死。
GlobalActor 与 @MainActor
有时需要让多个类型和方法共享同一个隔离域,这时可以定义全局 actor。
用 @globalActor 修饰一个 actor,它就成为可以标注到任意声明上的隔离标记(SE-0316,Swift 5.5 引入)。
实例
// 定义全局 actor:shared 是固定要求的静态属性
@globalActor
actor RunoobActor {
static let shared = RunoobActor()
}
@RunoobActor
func runoobTask() {
print("在自定义全局 actor 上执行")
}
@MainActor
final class UIState {
var text = "未加载"
func update(_ newText: String) {
text = newText
print("界面文字:\(text)")
}
}
await runoobTask()
func demoUI() async {
let state = await UIState()
await state.update("RUNOOB 已就绪")
}
await demoUI()
运行结果:
在自定义全局 actor 上执行 界面文字:RUNOOB 已就绪
@MainActor 就是标准库提供的全局 actor,它对应主线程。
UIKit 和 SwiftUI 的界面更新必须放在主线程,所以视图模型通常整个标成 @MainActor。
标了 @MainActor 不等于可以随便写耗时操作。它只保证在主线程执行,阻塞主线程照样会卡住界面。
重入与状态一致性
actor 的串行执行有一个容易被忽略的细节:await 是重入点。
当 actor 内的方法执行到 await 并挂起时,actor 可以去处理别的调用,等挂起结束后再回来。
这意味着「两次 await 之间,隔离状态可能已经被别人改过」。
实例
actor Loader {
private var cache: [String: String] = [:]
func load(_ key: String) async -> String {
if let cached = cache[key] {
return cached
}
// 这个 await 是重入点:挂起期间其他调用可以进入
try? await Task.sleep(for: .milliseconds(10))
let value = "\(key) 的数据"
cache[key] = value
return value
}
}
let loader = Loader()
print(await loader.load("runoob"))
print(await loader.load("runoob"))
运行结果:
runoob 的数据 runoob 的数据
第二次调用命中了缓存,直接返回,没有再次执行等待逻辑。
但这里其实有个隐患:如果两个调用几乎同时到达,都会发现缓存为空,然后各自挂起、各自计算、各自写入,重复劳动。
需要「只加载一次」的语义时,要把「检查缓存」和「标记正在加载」放在同一个不挂起的步骤里完成。
记住这条规则:await 之前成立的条件,await 之后不一定还成立。跨 await 的状态假设是 actor 代码最常见的 bug 来源。
常见问题
下面整理 actor 使用中最容易踩的几个坑。
actor 的方法可以直接调用吗
隔离方法不能,必须加 await;标了 nonisolated 的方法可以。
actor 的初始化器不隔离,所以创建实例不需要 await。
actor 能继承吗
不能。actor 不支持继承,也不能被类继承。
需要共享行为时,用协议加扩展来复用。
actor 属性能标 willSet / didSet 吗
不能。属性观察器要求属性的读写路径可控,而 actor 的隔离访问是异步的,两者不兼容。
actor 里的属性可以声明为 let 吗
可以,而且常量本身就不可变,从外部访问不需要 await。
actor 和 @MainActor 有什么区别
普通 actor 是独立实例,每个实例有自己的隔离域。
@MainActor 是全局 actor,所有标了它的代码共享同一个隔离域,并且这个域绑定在主线程上。
什么时候该用 actor
当一份可变状态会被多个并发任务访问时,用 actor 是最省心的选择。
如果状态只在单个任务里使用,或者根本不可变,就不需要 actor,加了反而带来不必要的 await。
