深入剖析 Swift Actors:六大陷阱与避坑指南

9 阅读7分钟

原文学习自:www.fractal-dev.com/blog/swift-…

Swift 5.5 引入 Actors 时,苹果承诺这将终结数据竞争问题。"只需把 class 换成 actor,问题就解决了"——但事实远比这复杂。

陷阱 1:Reentrancy(重入)——Actor 不是串行队列

这是最被低估的陷阱。大多数开发者认为 Actor 就像内置了 DispatchQueue(label: "serial") 串行队列的类。实际上并不是,这是个致命误解。

Actor 只保证一点:同一时刻只执行一个代码片段。 但在 await 之间,它可能处理完全不同的调用。

原理分析

actor BankAccount {
    var balance: Int = 1000

    func withdraw(_ amount: Int) async -> Bool {
        // 检查余额
        guard balance >= amount else { return false }

        // ⚠️ 挂起点 - 在此处 Actor 可以处理其他调用
        await authorizeTransaction()

        // 返回后余额可能已经改变!
        balance -= amount  // 可能变成负数!
        return true
    }

    private func authorizeTransaction() async {
        try? await Task.sleep(for: .milliseconds(100))
    }
}

let actor = BankAccount()
Task.detached {
    await actor.withdraw(800)
}
Task.detached {
    await actor.withdraw(800)
}

Task.detached {
    try await Task.sleep(nanoseconds: 200 * 1000_000)
    print(await actor.balance)
}

执行时序问题:

如果两个任务几乎同时调用 withdraw(800)

  1. 任务 A:检查 balance >= 800 → true
  2. 任务 A:等待 authorizeTransaction()
  3. 任务 B:进入 Actor,检查 balance >= 800 → true(仍然是1000!)
  4. 任务 B:等待 authorizeTransaction()
  5. 任务 A:返回,扣款800 → balance = 200
  6. 任务 B:返回,扣款800 → balance = -600 💥

为什么会这样设计?

Apple 故意选择重入设计来避免死锁。如果两个 Actor 互相等待对方——没有重入就是经典死锁。有了重入,你得到的是……微妙的状态 Bug。

解决方案:Task Cache 模式

核心思想:在第一个挂起点之前同步修改状态。

actor BankAccount {
    var balance: Int = 1000
    // 存储正在处理的交易任务
    private var pendingWithdrawals: [UUID: Task<Bool, Never>] = [:]

    func withdraw(_ amount: Int, id: UUID = UUID()) async -> Bool {
        // 如果已经在处理这笔交易,等待结果
        if let existing = pendingWithdrawals[id] {
            return await existing.value
        }

        // 在任何 await 之前同步检查余额
        guard balance >= amount else { return false }

        // 同步预留资金
        balance -= amount

        // 创建授权任务
        let task = Task {
            await authorizeTransaction()
            return true
        }
        pendingWithdrawals[id] = task

        let result = await task.value
        pendingWithdrawals[id] = nil

        // 如果授权失败,回滚
        if !result {
            balance += amount
        }
        return result
    }

    private func authorizeTransaction() async {
        try? await Task.sleep(for: .milliseconds(100))
    }
}

关键改变:状态变更发生在同步代码块中,在任何 await 之前。

注意:这只是解决重入问题的模式之一,并非唯一或总是最佳方案。其他替代方案包括:Actor + 纯异步服务拆分、乐观锁(optimistic locking),或在特定情况下使用 nonisolated + 锁。选择取决于具体用例。

陷阱 2:Actor Hopping——性能杀手

每次跨越 Actor 边界都是一次潜在的上下文切换。在循环中这可能是灾难。

性能问题

actor Database {
    func loadUser(id: Int) -> User {
        // 耗时操作
        User(id: id)
    }
}

@MainActor
class DataModel {
    let database = Database()
    var users: [User] = []

    func loadUsers() async {
        for i in 1...100 {
            // ❌ 200 次上下文切换!
            let user = await database.loadUser(id: i)
            users.append(user)
        }
    }
}

每次迭代:

  1. 从 MainActor 跳转到 Database Actor
  2. 从 Database Actor 跳回 MainActor

100 次迭代 = 200 次跳转。苹果在 WWDC 2021 "Swift Concurrency: Behind the Scenes" 中展示了这在 CPU 上的模式——像"锯齿"一样持续中断。

解决方案:批处理(Batching)

actor Database {
    // 批量加载用户
    func loadUsers(ids: [Int]) -> [User] {
        ids.map { User(id: $0) }  // 一次完成所有操作
    }
}

@MainActor
class DataModel {
    let database = Database()
    var users: [User] = []

    func loadUsers() async {
        let ids = Array(1...100)
        // ✅ 一次跳转去,一次跳转回
        let newUsers = await database.loadUsers(ids: ids)
        users.append(contentsOf: newUsers)
    }
}

何时真正影响性能?

在协作线程池(cooperative pool)内跳转很便宜。问题出现在与 MainActor 的跳转,因为主线程不在协作池中,需要真正的上下文切换。

经验法则:如果一次操作中有超过 10 次跳转到 MainActor,很可能架构有问题。

陷阱 3:@MainActor——虚假的安全感

这是 Swift 6 发布后捕获数百名开发者的陷阱。@MainActor 注解不总能保证在主线程执行。

问题根源

@MainActor
class ViewModel {
    var data: String = ""

    func updateData() {
        // Swift 5 中:可能不在主线程!
        data = "updated"
    }
}

// 在某个地方...
DispatchQueue.global().async {
    let vm = ViewModel()
    vm.updateData()  // ⚠️ 在后台线程执行!
}

关键区别:

  1. @MainActor 隔离性:保证状态访问被隔离到 MainActor(MainActor 与主线程绑定)
  2. 异步边界强制执行:但此保证只在调用跨越隔离边界(async boundary)时生效

当代码绕过这个边界——特别是与 Objective-C 遗留 API 交互时,问题就出现了。苹果框架的回调"不知道" Swift Concurrency,会直接调用你的方法,不经过异步边界。

换句话说:@MainActor 是编译时契约,只在编译器"看到"完整调用路径的地方强制执行。遗留 API 对它来说是个黑箱。

与遗留 API 交互的失败案例

案例 1:系统框架回调

import LocalAuthentication

@MainActor
class BiometricManager {
    var isAuthenticated = false

    func authenticate() {
        let context = LAContext()
        context.evaluatePolicy(
            .deviceOwnerAuthentication,
            localizedReason: "请登录"
        ) { success, _ in
            // ❌ 这个回调总是在后台线程!
            self.isAuthenticated = success  // 数据竞争!
        }
    }
}

案例 2:Objective-C 代理模式

import CoreLocation

@MainActor
class LocationHandler: NSObject, CLLocationManagerDelegate {
    var lastLocation: CLLocation?

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        // ❌ 可能从任意线程调用!
        lastLocation = locations.last
    }
}

解决方案:显式调度

// 方案 1:使用 async/await API
@MainActor
class BiometricManager {
    var isAuthenticated = false

    func authenticate() async {
        let context = LAContext()

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "请登录"
            )
            isAuthenticated = success  // ✅ 现在在 MainActor 上
        } catch {
            isAuthenticated = false
        }
    }
}

// 方案 2:使用 Task 显式跳转
extension LocationHandler {
    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        // 显式跳转到 MainActor
        Task { @MainActor in
            lastLocation = locations.last  // ✅ 安全
        }
    }
}

// 方案 3:使用 @MainActor 闭包
func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
) {
    // 显式在主线程执行
    DispatchQueue.main.async { @MainActor in
        self.lastLocation = locations.last
    }
}

陷阱 4:Sendable——编译器不会捕获所有问题

Sendable 协议标记可在隔离域之间安全传递的类型。但问题是:编译器经常放过不安全的代码。

编译器盲区示例

// 非线程安全的可变状态类
class UnsafeCache {
    var items: [String: Data] = [:]  // 可变状态,非线程安全
}

actor DataProcessor {
    func process(cache: UnsafeCache) async {
        // ⚠️ Swift 5 中编译无警告!
        cache.items["key"] = Data()  // 数据竞争!
    }
}

@unchecked Sendable:双刃剑

许多开发者为了消除编译器警告而添加 @unchecked Sendable

extension UnsafeCache: @unchecked Sendable {}

// 这告诉编译器:"相信我,我知道我在做什么"
// 但问题在于:大多数时候你并不知道

何时使用 @unchecked Sendable(合理场景)

  1. 技术上可变但实际不可变的类型(如延迟初始化)
  2. 有内部同步机制的类型(如使用锁或原子操作)
  3. 启动时初始化一次的 Singleton

何时绝对不要使用 @unchecked Sendable

  1. "为了让代码编译通过" ——这是最危险的理由
  2. 没有同步机制的可变状态类
  3. 你无法控制的第三方类型

更优方案:重构为 Actor

// ❌ 不要这样做
class UnsafeCache: @unchecked Sendable {
    var items: [String: Data] = [:]
}

// ✅ 更好的做法
actor SafeCache {
    private var items: [String: Data] = [:]
    
    // 提供安全的访问方法
    func get(_ key: String) -> Data? {
        items[key]
    }
    
    func set(_ key: String, _ value: Data) {
        items[key] = value
    }
    
    func remove(_ key: String) {
        items.removeValue(forKey: key)
    }
}

// 使用示例
actor DataProcessor {
    let cache = SafeCache()  // 强制通过 Actor 访问
    
    func process() async {
        await cache.set("key", Data())
        let data = await cache.get("key")
    }
}

陷阱 5:nonisolated 不意味着 thread-safe

nonisolated 关键字仅表示方法/属性不需要 Actor 隔离,不表示它是 thread-safe 的。

常见误解

actor Counter {
    private var count = 0

    // ✅ 正确:不访问 Actor 状态
    nonisolated var description: String {
        "Counter instance"  // OK,不触碰状态
    }

    // ❌ 编译错误:不能访问 Actor 隔离的状态
    nonisolated func badIdea() {
        // 错误:Actor-isolated property 'count' 
        // cannot be referenced from a non-isolated context
        print(count)
    }
}

典型错误:为协议一致性使用 nonisolated

actor Wallet: CustomStringConvertible {
    let name: String          // 常量,非隔离
    var balance: Double = 0   // Actor 隔离状态

    // 为符合协议必须实现 nonisolated
    nonisolated var description: String {
        // ❌ 错误:"\(name): \(balance)" 会失败
        
        // ✅ 只能访问不可变状态:
        name
    }
}

正确实现协议的方式

actor Wallet: CustomStringConvertible {
    let name: String
    private(set) var balance: Double = 0
    
    // 提供 Actor 隔离的更新方法
    func deposit(_ amount: Double) {
        balance += amount
    }
    
    // nonisolated 只能访问非隔离成员
    nonisolated var description: String {
        "Wallet(name: \(name))"
    }
    
    // 提供异步获取完整描述的方法
    func detailedDescription() async -> String {
        await "\(name): $\(balance)"
    }
}

Swift 6.2 的新变化

MainActorIsolationByDefault 模式下,nonisolated 获得新含义:表示"继承调用者的隔离性"。

// 启用 MainActorIsolationByDefault = true
class DataManager {
    // 默认 @MainActor
    func processOnMain() { }
    
    // 继承调用者上下文(更灵活)
    nonisolated func processAnywhere() { }
    
    // 明确在后台执行
    @concurrent
    func processInBackground() async { }
}

这是范式转变——nonisolated 不再表示"无隔离",而是表示"灵活隔离"。

陷阱 6:Actor 不保证调用顺序

这让许多从 GCD 转来的开发者吃惊:Actor 不保证外部调用的执行顺序。

顺序的不确定性

actor Logger {
    private var logs: [String] = []

    func log(_ message: String) {
        logs.append(message)
    }

    func getLogs() -> [String] { logs }
}

let logger = Logger()

// 从非隔离上下文
for i in 0..<10 {
    Task.detached {
        try await Task.sleep(nanoseconds: UInt64(arc4random()) % 1000000)
        await logger.log("Message \(i)")
    }
}
Task {
    try await Task.sleep(nanoseconds: 200 * 1000_000)
    print(await logger.getLogs())
}

// 结果可能是:[0, 2, 1, 4, 3, 6, 5, 8, 7, 9]
// 或任何其他排列组合!

为什么如此?

必须区分两个概念:

  1. Actor 邮箱是 FIFO - Actor 按消息进入邮箱的顺序处理
  2. 任务调度不是 FIFO - 但任务向 Actor 邮箱发送消息的顺序是不确定的

简单说:入队顺序 ≠ 执行顺序。每个 Task 是独立的工作单元,调度器可以按任意顺序运行它们,所以消息以不可预测的序列进入 Actor 邮箱。Actor 只保证 log() 不会并行执行——但不保证消息到达的顺序。

解决方案:显式排序

actor OrderedLogger {
    private var logs: [String] = []
    private var pendingTask: Task<Void, Never>?

    func log(_ message: String) async {
        // 等待前一个任务完成
        let previousTask = pendingTask
        
        // 创建新任务,依赖前一个任务
        pendingTask = Task {
            await previousTask?.value  // 等待前置任务
            logs.append(message)
        }
        
        // 等待当前任务完成
        await pendingTask?.value
    }
}

// 更高效的串行队列实现
actor SerialLogger {
    private var logs: [String] = []
    private let queue = AsyncSerialQueue()  // 使用第三方库
    
    nonisolated func log(_ message: String) -> Task<Void, Never> {
        Task(on: queue) {
            await self.appendLog(message)
        }
    }
    
    private func appendLog(_ message: String) {
        logs.append(message)
    }
}

实践检查清单

在将类转为 Actor 前,请回答以下问题:

✅ 适合使用 Actor 的场景

  • 有在任务间共享的可变状态
  • 需要线程安全而无需手动同步
  • 状态操作主要是同步的

❌ 不适合使用 Actor 的场景

  • 需要严格保证操作顺序
  • 所有操作都是异步的(重入会成为问题)
  • 有性能关键代码且包含大量小操作
  • 需要同步访问状态

🔍 关键检查问题

  1. 在修改状态的方法内部有 await 吗? → 重入风险
  2. 在循环中调用 Actor 吗? → Actor 跳转风险
  3. 用 @MainActor 配合代理/回调吗? → 线程安全风险
  4. 使用 @unchecked Sendable 吗? → 为什么?有充分理由吗?
  5. 依赖操作顺序吗? → Actor 不保证顺序

原理总结与扩展场景

核心设计权衡

Swift Actors 的设计体现了深刻的取舍哲学:

设计目标实现方式带来的代价
避免死锁重入机制(Reentrancy)状态在 await 点可能变化
编译时安全Sendable 检查需要 @unchecked 绕过检查
性能优化协作线程池MainActor 跳转成本高
灵活隔离nonisolated / @MainActor可能绕过运行时保证

扩展场景 1:混合架构中的 Actor

在大型项目中,Actor 需要与现有 GCD/OperationQueue 代码共存:

// 将 GCD 队列包装为 Actor
actor LegacyDatabaseBridge {
    private let queue = DispatchQueue(label: "database.serial")
    
    // 在 Actor 方法中同步调用 GCD
    func query(_ sql: String) async -> [Row] {
        await withCheckedContinuation { continuation in
            queue.async {
                let results = self.executeQuery(sql)
                continuation.resume(returning: results)
            }
        }
    }
    
    private func executeQuery(_ sql: String) -> [Row] {
        // 传统实现
        []
    }
}

扩展场景 2:Actor 与 SwiftUI

// SwiftUI ViewModel 的合理模式
@MainActor
class ProductViewModel: ObservableObject {
    @Published private(set) var products: [Product] = []
    @Published private(set) var isLoading = false
    
    private let service = ProductService()  // 非 MainActor
    
    func loadProducts() async {
        isLoading = true
        defer { isLoading = false }
        
        // 一次性跳转到后台 Actor
        let newProducts = await service.fetchProducts()
        products = newProducts  // 回到 MainActor 后一次性更新
    }
}

// 产品服务在后台 Actor
actor ProductService {
    func fetchProducts() -> [Product] {
        // 耗时网络/数据库操作
        []
    }
}

扩展场景 3:高吞吐量数据处理

// 处理大量小任务的优化模式
actor DataProcessor {
    private var buffer: [Data] = []
    private let batchSize = 100
    
    // 非隔离方法,快速入队
    nonisolated func process(_ data: Data) {
        Task { await self.addToBuffer(data) }
    }
    
    private func addToBuffer(_ data: Data) {
        buffer.append(data)
        
        // 批量处理
        if buffer.count >= batchSize {
            let batch = buffer
            buffer.removeAll()
            
            Task {
                await self.processBatch(batch)
            }
        }
    }
    
    private func processBatch(_ batch: [Data]) async {
        // 耗时操作
        try? await Task.sleep(for: .milliseconds(10))
    }
}

总结

Swift Actors 是强大工具,但不是魔法棒。理解其局限性是编写正确、高效代码的关键。

六大核心教训:

  1. 重入(Reentrancy):await 之间状态可能改变,在写代码的时候要牢记这一点
  2. Actor 间跳转:MainActor 跳转成本高,尽量在单个actor中批量操作
  3. @MainActor :编译时提示,非运行时保证(尤其是与遗留 API 交互时)
  4. Sendable:@unchecked 是最后手段,三思而行
  5. nonisolated:不表示线程安全,只是不需要隔离
  6. 执行顺序:Actor 不保证调用顺序(入队顺序 ≠ 执行顺序)

简单法则:Actor 适合保护同步状态变更,不适合异步流程控制。需要顺序执行?用串行队列。需要并发执行?用并行任务。需要状态安全?用 Actor。