现在位置: 首页 > Swift 教程 > 正文

Swift actor 与数据隔离

多个任务同时修改同一份数据,就会出现数据竞争:两个任务都读到旧值,各自加一,结果只加了一次。

传统的解决办法是加锁,但锁容易漏、容易死锁,编译器也帮不上忙。

Swift 用 actor(SE-0306,Swift 5.5 引入)把「这份状态只能串行访问」变成类型系统的一部分,由编译器检查。


为什么需要 actor

先看问题本身。下面的类没有任何保护,多个任务并发调用会互相覆盖。

即使只有一个 += 1,它也不是原子操作:读值、加一、写回三步之间可能被打断。

Swift 的并发检查会在编译期指出这类共享可变状态的问题,而 actor 就是给出的解法。

做法谁保证安全主要风险
不加保护没人,靠自觉数据竞争,结果不可预测
手动加锁开发者,靠纪律漏加锁、死锁、锁粒度不当
actor编译器检查使用上有 await 的语法负担

actor 的定义与隔离状态

actor 的写法和 class 很像,区别在于它的可变状态默认被隔离。

隔离的意思是:外部不能直接读写这些属性,只能通过 actor 自己的方法来访问。

定义一个 actor

下面的计数器把 value 设为私有,只暴露两个方法。

实例

import Foundation

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

实例

import Foundation

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

depositcurrentBalance 都碰了 balance,所以必须保持隔离,调用时要 await

nonisolated 只免除隔离,不提供任何同步保证。标之前先确认这个方法真的没碰共享可变状态,否则等于绕过了编译器给你的保护。


actor 与锁的对比

在 actor 出现之前,保护共享状态的常用手段是锁。

下面用 NSLock 实现同样的计数器,效果和 actor 一样,但安全性完全靠开发者自己保证。

实例

import Foundation

// @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 引入)。

实例

import Foundation

// 定义全局 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 之间,隔离状态可能已经被别人改过」。

实例

import Foundation

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。