Swift Actor 完全指南:从原理到实战,彻底告别数据竞争

433 阅读5分钟

为什么需要 Actor?

在 iOS 开发中,并发编程一直是“高并发 → 高崩溃”的重灾区。

传统锁(NSLock、os_unfair_lock、DispatchQueue.barrier)存在两大痛点:

  1. 容易死锁
  2. 无法静态检查:编译器不帮你排雷,运行时才爆炸

Swift 5.5 引入的 Actor 把“线程安全”变成了编译器义务:“只要代码能编译,就不会出现数据竞争。”

这是通过 Actor 隔离(actor isolation) 做到的。

Actor 的本质

Actor 是一个引用类型,与 class 类似,但额外拥有:

能力classactor
继承❌(final,不可继承)
多线程共享可变状态需手动同步编译器自动同步
直接访问可变属性❌(必须 await)
方法默认线程安全

一句话:Actor = class + 编译期线程安全检查器。

定义你的第一个 Actor

// 1. 用关键字 actor 而不是 class
actor Shop {
    // 2. 常量可同步读取,无需 await
    let id = "abc"
    
    // 3. 可变状态,默认受 Actor 隔离保护
    private(set) var itemsCount = 10
    
    // 4. 修改状态的方法,无需加锁
    func purchase() {
        guard itemsCount > 0 else { return }
        itemsCount -= 1
    }
    
    // 5. 只读计算属性,同样受隔离
    var isSoldOut: Bool {
        itemsCount == 0
    }
}

与 Actor 交互的语法规则

访问场景语法是否挂起
读取常量shop.id
读取/修改可变状态await shop.itemsCount
调用隔离方法await shop.purchase()

示例:

let shop = Shop()

// ✅ 同步读取常量
print("Shop ID:", shop.id)

// ❌ 编译错误:同步读取可变状态
// print(shop.itemsCount)

// ✅ 异步读取
Task {
    await shop.purchase()
    let count = await shop.itemsCount
    print("剩余库存:", count)
}

Actor 隔离细则

  1. 外部同步访问可变状态 → 禁止

    编译期直接报错:Actor-isolated property 'itemsCount' can only be referenced asynchronously

  2. 外部异步访问 → 必须 await

    编译器插入隐式挂起点,保证同时仅一个任务进入 Actor 内部。

  3. 内部访问 → 可同步

    Actor 自身方法之间互相调用无需 await,因为已经拿到“通行证”。

  4. 常量(let) → 可同步

    不可变数据天生线程安全。

nonisolated:打破隔离的“逃生舱”

有时我们需要不挂起就能调用 Actor 的某些方法,例如日志、计算哈希值等只读操作:

extension Shop {
    // 1. 标记为 nonisolated,脱离隔离域
    nonisolated func log(_ message: String) {
        // 2. 内部只能使用非隔离成员(常量或其他 nonisolated 成员)
        print("[Shop \(id)] \(message)")
    }
}

// 使用:无需 await
shop.log("Hello Actor")

注意:

  • nonisolated 方法内部绝不能直接读取可变状态,否则编译器报错。
  • 适合纯函数、日志、Debug 描述等场景。

Sendable:跨 Actor 传输的“安检证”

Actor 保护了自己的内部状态,但数据总要“进出口”。

Swift 用 Sendable 协议标记线程安全类型,确保跨任务传递时不会引发数据竞争。

类型是否默认 Sendable说明
Int、String、Array、Dictionary(值类型)值拷贝,线程安全
struct / enum(值类型)所有存储属性也必须是 Sendable
class可变共享状态,需手动保证
@MainActor 标注的类主线程独占,可安全传递

示例:

// ✅ struct 默认符合 Sendable(需要内部所有变量是Sendable的)
struct Owner: Sendable {
    let name: String
}

actor Shop {
    var owner: Owner   // 编译通过
    init(owner: Owner) {
        self.owner = owner
    }
}

// ❌ 可变 class 不符合 Sendable
// Non-final class 'MutableOwner' cannot conform to 'Sendable'; use '@unchecked Sendable'
class MutableOwner:  Sendable {   // 编译错误:MutableOwner is a non-final class
    var name: String = ""
}

// ✅ 不可变 + final,手动标记 Sendable
final class ImmutableOwner: Sendable {
    let name: String
    init(name: String) { self.name = name }
}

实战建议:“能 struct 就别 class” 是并发编程里最便宜的保险。

Actor 可重入(Reentrancy):性能与风险的双刃剑

当 Actor 方法内部 await 其他异步调用时,Actor 会挂起并释放线程,此时等待队列里的其他任务可提前进入。

优点:提高吞吐量; 缺点:状态可能被并发修改,导致逻辑错误。

示例:

actor BankAccount {
    var balance = 100
    
    func withdraw(_ amount: Int) async -> Bool {
        // 1. 检查余额
        guard balance >= amount else { return false }
        
        // 2. 模拟网络请求(挂起点)
        await Task.sleep(nanoseconds: 100_000_000)
        
        // 3. 再次检查时余额可能已变!
        balance -= amount
        return true
    }
}

解决方案:

  1. 把“检查 + 扣款”做成原子事务,避免中间挂起。
  2. 使用状态机或锁变量(例如 enum State { case idle, withdrawing })显式管理重入。
  3. 若业务允许,直接顺序化队列(将相关操作放到同一个 Task 串行执行)。

完整实战:线程安全的购物车

import Foundation

// 1. 商品模型
struct Item: Sendable {
    let id: String
    let name: String
    var stock: Int
}

// 2. 购物车 Actor
actor ShoppingCart {
    private var items: [String: Int] = [:] // [itemID: count]
    
    // 添加商品
    func add(_ item: Item, quantity: Int = 1) -> Bool {
        guard item.stock >= quantity else {
            return false
        }
        items[item.id, default: 0] += quantity
        return true
    }
    
    // 移除商品
    func remove(itemID: String, quantity: Int = 1) {
        if let current = items[itemID] {
            let new = max(current - quantity, 0)
            if new == 0 {
                items.removeValue(forKey: itemID)
            } else {
                items[itemID] = new
            }
        }
    }
    
    // 总价计算(只读,可 nonisolated)
    nonisolated func totalPrice(using priceProvider: (String) -> Double) -> Double {
        // 注意:这里无法直接读取 items,只能把计算逻辑交给调用方
        priceProvider("你自己计算")
    }
    
    // 打印清单(调试)
    func dump() {
        print("Cart contents:", items)
    }
}

// 3. 使用
let cart = ShoppingCart()
let banana = Item(id: "b1", name: "Banana", stock: 5)

Task {
    let success = await cart.add(banana, quantity: 3)
    print("添加3根香蕉到购物车 \(success ? "成功" : "失败")")
    await cart.dump()
    let totalPrice = cart.totalPrice { str in
        print("总计花费15元")
        return 15
    }
    print("总价 \(totalPrice)")
}

RunLoop.main.run(until: Date.now + 1)

Actor 的扩展使用场景

场景用法备注
图片缓存actor ImageCache多任务同时下载,避免重复写入磁盘
数据库连接actor DatabaseQueue将 Core Data / SQLite 操作序列化
日志系统actor Logger异步写文件,不阻塞主线程
状态机actor StateMachine游戏主循环、下载管理器
限流器actor RateLimiter控制 API 调用频率

总结与建议

  1. Actor 不是银弹:它解决的是“共享可变状态”导致的数据竞争,但死锁、逻辑竞争、重入问题依然存在,需要业务层设计。
  2. 优先值类型:struct + let 是最便宜的线程安全。
  3. 减少跨 Actor 调用链:频繁 await 会导致“回调地狱 2.0”,可通过批量接口或事件流聚合。
  4. 单元测试:使用 XCTUnwrap(await shop.itemsCount) 直接断言 Actor 内部状态,无需暴露锁。
  5. 渐进迁移:现有项目可先把“单例管理器”改成 actor,再逐步下沉到更细粒度对象。

延伸阅读