Protect mutable state with Swift actors
Swift & UI 进阶 28m

用 Swift Actor 保护可变状态

Protect mutable state with Swift actors

2021年6月7日

在 Apple 官方观看视频

一句话判断

Actor 不是高级可选特性——在 Swift Concurrency 的世界里,它是你保护共享可变状态的默认工具,该把锁和串行队列从你的工具箱里慢慢淘汰掉了。

这场 Session 讲了什么

Session 从数据竞争(Data Race)问题切入,展示了传统并发编程中用锁(Lock)、串行队列(Serial Dispatch Queue)保护共享状态的种种痛点:容易遗漏、难以测试、死锁风险。然后引出 Swift 5.5 的 actor 类型——一种在语言层面保证线程安全的抽象。

Actor 是一种特殊的引用类型,它的所有可变状态都被隔离(isolated)在 actor 内部。外部代码访问 actor 的属性或方法时,必须通过 await 显式标记这是一个潜在的挂起点。编译器会强制检查这条规则——如果你忘了 await,代码根本编译不过。Session 用一个银行账户的例子贯穿始终:转账操作需要同时修改两个账户的余额,用 actor 之后这些操作的串行化由编译器和运行时保证,不需要手动加锁。

值得深挖的点

Actor Isolation 的边界在哪里

Actor 的隔离不是”全有或全无”的。nonisolated 关键字可以让某些方法脱离 actor 的保护,适用于只读计算属性或纯函数。但要注意:nonisolated 方法不能访问 actor 的可变存储属性——编译器会拦住你。这个设计确保了隔离不会被意外打破。另一个容易被忽略的细节:actor 的 deinitnonisolated 的,因为 actor 可能在 deinit 执行时已经没有执行者了。

Actor 与 Sendable 协议的关系

Actor 内部的状态天然受到保护,但跨 actor 传递的数据呢?这就是 Sendable 协议的作用。Swift 5.5 引入了 Sendable 用来标记”可以安全跨并发域传递”的类型。值类型(String、Int、Struct)如果只包含 Sendable 成员,自动遵循 Sendable。引用类型需要显式声明。跨 actor 传递非 Sendable 类型时,编译器会发出警告——这是数据竞争隐患的信号。

代码片段

// 一个银行账户 actor
// 所有状态修改都被 actor 隔离保护,不会出现数据竞争
actor BankAccount {
    let id: String
    private var balance: Double
    
    init(id: String, initialDeposit: Double) {
        self.id = id
        self.balance = initialDeposit
    }
    
    // actor 内部方法,直接访问可变状态
    func deposit(_ amount: Double) {
        guard amount > 0 else { return }
        balance += amount
    }
    
    func withdraw(_ amount: Double) -> Bool {
        guard amount <= balance else { return false }
        balance -= amount
        return true
    }
    
    // nonisolated 不需要 await 调用,但不能访问可变状态
    nonisolated var description: String {
        "账户 \(id)"  // 只能访问不可变属性
    }
}
// 跨 actor 转账操作
// 每次访问 actor 的方法都需要 await
func transfer(amount: Double, from: BankAccount, to: BankAccount) async -> Bool {
    // 先从源账户扣款
    let success = await from.withdraw(amount)
    guard success else { return false }
    
    // 再存入目标账户
    await to.deposit(amount)
    return true
}

// 使用示例
let alice = BankAccount(id: "alice", initialDeposit: 1000)
let bob = BankAccount(id: "bob", initialDeposit: 500)

// 多个并发转账不会导致数据竞争
async let t1 = transfer(amount: 100, from: alice, to: bob)
async let t2 = transfer(amount: 200, from: bob, to: alice)
let results = await (t1, t2)
// Sendable 类型的定义
// 跨 actor 边界传递的类型必须遵循 Sendable
struct Transaction: Sendable {
    let from: String    // let 属性自动满足 Sendable
    let to: String
    let amount: Double
    let timestamp: Date // Date 是值类型,自动 Sendable
}

actor TransactionLogger {
    private var history: [Transaction] = []
    
    func record(_ transaction: Transaction) {
        // Transaction 是 Sendable,安全地跨 actor 传入
        history.append(transaction)
    }
}

最佳实践

  • 新项目直接用 actor 替代 DispatchQueue(label:).syncNSLock。老项目迁移时,先把最外层的状态管理对象改成 actor,内部细节逐步跟进。
  • 不要把 actor 当成万能锁——如果一个操作涉及多个 actor 的状态协调(比如转账),应该在更高的抽象层用 async/await 编排,而不是在 actor 内部嵌套 actor 调用。
  • 为 actor 的关键操作编写单元测试时,注意测试方法本身需要在 async 上下文中运行(用 await 调用 actor 方法),XCTest 在 Xcode 13 中已经原生支持 async 测试。

还有什么值得关注

  • @MainActor 是一个特殊的全局 actor,标记后代码会在主线程执行——用 @MainActor 标记 UIViewController 的子类,就能告别 DispatchQueue.main.async 了。
  • Actor 的 reentrancy(重入)特性需要特别注意:一个 actor 方法在 await 挂起期间,其他调用可以进入同一个 actor。这不是 bug,是设计,但可能导致状态不一致。
  • Swift 5.5 的 @globalActor 可以让你自定义全局 actor,适用于特定领域的隔离需求。
WWDC 2021