Swift Actor:从使用范式到底层实现

5 阅读11分钟

基于 Swift 5.10 / Swift 6 严格并发模式。 文中实战案例取自某大型 iOS 客户端的小程序模块的 WebSocket 实现。


核心要点

维度Actor传统 class + 锁
数据保护编译器强制:状态访问必须在 actor 上完全靠开发者自觉加锁
调用形式跨 actor 必须 await调用者无感知
调度模型每个 actor 实例一个 serial executor,job 串行执行锁的粒度由开发者决定
死锁风险几乎为零(不会持锁等锁)多锁顺序错就死锁
重入风险有:await 后状态可能已变同步路径不存在重入
静态数据竞争检查Swift 6 + Sendable 编译期校验
性能一次 actor hop ≈ 一次原子操作 + 可能的线程调度os_unfair_lock 通常更轻

一句话:actor 是 Swift 把"对可变状态的串行访问"从一个运行时纪律问题升级为一个编译期类型问题所引入的引用类型。底层依赖 ExecutorJob Queue 这一对协作机制,把每次跨 actor 调用编译成"hop 到该 actor 的 executor 上执行"。


1. 什么是 Actor:使用层面速览

actor 是 Swift 5.5 引入的一个新类型类别,与 class / struct / enum 并列。语法上几乎和 class 一样:

actor Counter {
    private var value = 0
    func increment() { value += 1 }
    func read() -> Int { value }
}

但它带来三条编译器规则:

  1. 同一个 actor 实例的方法 / 属性访问被串行化
  2. 从 actor 之外访问其可变状态必须 await
    let c = Counter()
    await c.increment()
    let v = await c.read()
    
  3. 所有跨 actor 边界传递的数据必须满足 Sendable(Swift 6 起强制)。

actor 是引用类型(和 class 一样有 identity、堆分配、参与 ARC),但不允许继承(除了继承自 Actor 协议本身)。


2. Actor 隔离:语言模型

理解 actor 的关键概念是 isolation domain(隔离域)。每个 actor 实例就是一个独立的隔离域;同一个域里的方法/属性访问是同步的,跨域则是异步的

2.1 三种隔离状态

标注含义调用方需要 await
默认(actor 内成员)属于该 actor 的隔离域跨 actor 时需要
nonisolated不属于任何 actor 域,与同步函数同
isolated 参数(SE-0313)把外部函数"借进"某个 actor 域函数内部直接访问,无需 await
actor Cache {
    private var store: [String: Data] = [:]

    func read(_ key: String) -> Data? { store[key] }    // isolated(默认)

    nonisolated let id: UUID = UUID()                    // 不可变,安全暴露
    nonisolated func describe() -> String { "Cache-\(id)" }
}

nonisolated 常用于:

  • 不可变属性 / 纯函数;
  • Hashable / Equatable / CustomStringConvertible 的协议实现(这些方法上游通常是同步上下文)。

2.2 编译器在背后做的事

当你写:

await counter.increment()

编译器会简单地把它翻译成"加锁—调用—解锁",而是:

  1. 把当前函数在该处切成两段(暂停点);
  2. 把"恢复后的代码"打包成一个 partial task
  3. 把这个 task 提交给 counter 所属的 executor
  4. 当前线程立刻让出,去做别的事;
  5. counter 的 executor 取到这个 task 时,再在 executor 选定的线程上执行。

这就是常说的 actor hop


3. 底层实现:Executor 与 Job

Actor 的并发安全不是"加锁"出来的,而是"调度"出来的。整个体系建立在两个协议之上。

3.1 ExecutorSerialExecutor

public protocol Executor: AnyObject, Sendable {
    func enqueue(_ job: consuming ExecutorJob)
}

public protocol SerialExecutor: Executor {
    func asUnownedSerialExecutor() -> UnownedSerialExecutor
}

每个 actor 实例都关联一个 SerialExecutor,默认是 Swift Concurrency 提供的 default actor executor——它本质上是一个绑定在该 actor 上的 FIFO 队列,依附于 Swift Concurrency 的 cooperative thread pool

actor 自身存储里,除了用户定义的字段,还有一段运行时维护的 header,伪代码相当于:

struct DefaultActor {
    HeapMetadata metadata;            // 类元数据
    AtomicState state;                // 锁位 + 队列状态
    JobQueue queue;                   // 排队中的 job
    // ↓ 用户定义的存储属性
    ...
};

AtomicState 用 CAS 维护"是否有线程正在为该 actor 跑 job"以及"队列是否非空"两类标志,避免了真正意义上的内核锁。

3.2 一次 await someActor.foo() 究竟做了什么

简化后的执行流程:

当前 task                         actor 的 executor 队列
    │
    │  await actor.foo()
    ├──► 把 "恢复后续代码" 打包成 ExecutorJob ──┐
    │                                            ▼
    │                                   [job1, job2, foo-job]
    │                                            │
    │                                            │ executor 取下一个 job
    │                                            ▼
    │                                   线程 T 执行 foo() 函数体
    │                                            │
    │                                            ▼
    │                                   返回值通过 continuation 唤醒 task
    │  ◄────────────────────────────────────────┘
    ▼
 继续下一行

要点:

  • foo() 的函数体本身还是串行的,因为它跑在 actor 的 serial executor 上,executor 一次只取一个 job。
  • 当前 task 不会"持锁等结果",而是被 suspend——线程被释放回 cooperative pool,可以服务其他 task。这是 Swift Concurrency "suspend 不阻塞线程"的核心。
  • 调用者和被调用 actor 的 executor 不一定是同一个线程;一次 await 通常意味着一次线程跨越(hop)。

3.3 同 actor 内部的调用:编译器会优化

actor Foo {
    func a() { b() }      // 同 actor 内调用
    func b() { ... }
}

a()b() 不需要 await,因为它们已经在同一个隔离域内执行;编译器知道 self 的 executor 就是当前正在跑的 executor,直接走同步调用,没有 hop 成本。

3.4 自定义 Executor(SE-0392)

Swift 5.9 之后允许 actor 指定自己的 executor:

actor BackgroundWorker {
    nonisolated let unownedExecutor: UnownedSerialExecutor

    init(queue: DispatchQueue) {
        self.unownedExecutor = queue.asUnownedSerialExecutor()
    }
    ...
}

这给了你两件事:

  • 把 actor 的 job 桥接到一个已有的 DispatchQueue 或自定义线程池;
  • 控制 actor 的优先级、QoS、底层线程归属——这是音视频、IO 等对线程亲和性敏感的场景非常重要的能力。

4. 重入(Reentrancy):Actor 的暗坑

Actor 不会死锁,但会"重入"。这是从锁迁移到 actor 时最容易踩的概念。

actor 方法在遇到 await 时会主动让出自己的执行权,让 executor 去跑别的排队 job——包括对该 actor 自身的其它调用。所以下面这种写法是不安全的:

actor ImageCache {
    private var cache: [URL: UIImage] = [:]

    func image(for url: URL) async -> UIImage? {
        if let img = cache[url] { return img }       // ① 检查
        let img = await downloader.fetch(url)        // ② await,期间其他调用可能改 cache
        cache[url] = img                             // ③ 写入
        return img
    }
}

并发两次 image(for: sameURL)重复发起下载:第一次走到 ② 让出执行权后,第二次进入 ①,发现仍是空。

修法是用一张"in-flight task 表"做去重,把"开始一次下载"这个动作做成同步、原子的:

actor ImageCache {
    private var cache: [URL: UIImage] = [:]
    private var inFlight: [URL: Task<UIImage, Never>] = [:]

    func image(for url: URL) async -> UIImage {
        if let img = cache[url] { return img }
        if let t = inFlight[url] { return await t.value }

        let task = Task { await downloader.fetch(url) }
        inFlight[url] = task
        let img = await task.value
        cache[url] = img
        inFlight[url] = nil
        return img
    }
}

经验法则:actor 内每个 await 都是一个潜在的"状态可能已变"分界点。任何"先检查、再写入"的复合操作,都要么避免 await 横跨,要么显式记录 in-flight 状态。


5. Sendable:跨 actor 边界的数据安全

actor 解决了"对自己状态的并发访问",但传进/传出 actor 的数据也可能引发竞争——比如把一个共享 class 实例传进 actor,actor 内外同时改这个实例就乱了。

Sendable 协议就是用来标记"这个类型在多线程间传递是安全的":

类型是否自动 Sendable
IntStringBool 等值类型
全部字段都是 Sendable 的 struct / enum是(自动合成)
final class 且只有 let Sendable 字段可手动声明 : Sendable
普通 class否(需要 @unchecked Sendable + 自行保证)
actor自身就是 Sendable(其状态被隔离)
闭包跨 actor 时需要 @Sendable

Swift 6 的严格并发模式下,下列代码会编译报错

class Box { var value = 0 }
let box = Box()
await someActor.store(box)        // ❌ Box 不是 Sendable

这就是博客对照表里 "Swift 6 起编译器静态检查数据竞争" 的真正实现路径——它由 actor + Sendable 联合提供。


6. 全局 Actor 与 @MainActor

除了实例 actor,Swift 还提供了"全局 actor"概念——本质是一个全局唯一的 actor 实例,附带一个属性包装器,可以把任何类型/方法标记为属于该 actor。

最常用的就是 @MainActor:它的 executor 就是主队列。

@MainActor
final class HomeViewModel {
    var items: [Item] = []
    func reload() async {
        let data = await api.fetch()      // hop off main
        items = data                       // 安全:仍在 MainActor 上
    }
}

底层和实例 actor 完全相同——MainActor.shared 是一个全局 actor 实例,它的 unownedExecutor 桥接到 dispatch_get_main_queue()。任何 @MainActor 标注的方法,编译器都会插入 hop 逻辑。

实践意义:UI 代码全标 @MainActor,可以由编译器保证"所有 UI 操作都发生在主线程",这在 OC 时代几乎只能靠纪律和 review。


7. 实战案例:MinisWSConnection

下面以一个小程序容器中的 WebSocket 实现为例(Modules/Minis/Sources/WebSocket/MinisWSConnection.swift),看看 actor 在真实业务里如何替代锁。

WebSocket 连接里有三个高频被读写的字段:

@available(iOS 17, *)
actor MinisWSConnection {
    private(set) var state: MinisWSConnectionState = .disconnected
    private var outbound: NIOAsyncChannelOutboundWriter<WebSocketFrame>?
    private var channel: Channel?
}

它们的访问者来自三处不同的并发上下文:

来源触发操作
业务调用方(MinisWSClient / MinisWSServer主流程发消息 / 关闭sendTextcloseisActive
帧处理 actor MinisWSFrameProcessor解到 PING / CLOSE 帧sendFrame(pongFrame)
NIO 建链回调握手成功configure(outbound:channel:)

如果用普通 class,必须为这些字段配一把锁、并保证每个方法全程持锁;像 close() 这种"改状态 → 写出帧 → 清字段 → 再改状态"的复合不变量,写错任何一步就是数据竞争或半连接泄漏。

actor 写法把锁完全消除:

func close(code: WebSocketErrorCode = .normalClosure) async throws {
    guard let outbound, let channel else { throw MinisWSError.notConnected }
    guard state == .connected else { return }

    state = .closing
    let closeFrame = MinisWSFrameBuilder.closeFrame(...)
    try await outbound.write(closeFrame)
    outbound.finish()
    self.outbound = nil
    self.channel = nil
    state = .disconnected
}

调用方被强制 await,包括只读属性:

if let connection, await connection.isActive {
    try await connection.close()
}

isActive 看似一个 computed property,但因为它读了 actor 内部的可变状态,外部访问就必须暂停等队列。

⚠️ 实战提示close() 中段有 await outbound.write(closeFrame),这里会发生重入——其它对该 actor 的调用可能在此时插队进入。所以前面用了 state = .closing 作为门闩,让别的入口在 guard 里直接弹出,这就是典型的"用状态机配合 actor 防重入"。

同一文件里 MinisWSFrameProcessor 是另一个 actor,它持有 MinisWSConnection

actor MinisWSFrameProcessor {
    private let connection: MinisWSConnection
    ...
    private func handlePing(_ frame: WebSocketFrame) async throws {
        let pongFrame = MinisWSFrameBuilder.pongFrame(for: frame)
        try await connection.sendFrame(pongFrame)   // 跨 actor 调用 → hop
    }
}

注意这里仍然要 await——actor 之间不会因为彼此都是 actor 就跳过隔离,每次跨域都是一次 executor hop。这种 actor-of-actor 设计让多个 actor 可以独立串行互相协作,避免了"一个大锁锁所有"的瓶颈。


8. 性能特征与取舍

8.1 一次 actor hop 的开销

粗略组成:

  • 一次 CAS(修改 actor 的 state 标志位);
  • 一次 job 入队 / 出队;
  • 0~1 次线程切换(取决于 cooperative pool 的当前状态与目标 executor)。

数量级上:

  • 同 actor 内部调用 ≈ 普通函数调用(编译器消除了 hop);
  • 跨 actor 调用 ≈ 几十 ns ~ 几百 ns 量级(无线程切换时);
  • 涉及 @MainActor 跨线程 ≈ 一次 GCD dispatch 的成本。

8.2 与传统并发原语对比

原语调度成本阻塞线程编译期检查适用场景
os_unfair_lock极低(几 ns)是(短自旋后阻塞)极短临界区、热路径
NSLock / @synchronized一般临界区
DispatchQueue 串行队列中(含一次 dispatch)否(任务被排队)异步串行执行
actor中(hop + 队列)(隔离 + Sendable)长期持有的可变状态

要点:

  • 热路径加锁仍可能比 actor 快,因为 actor 必然走异步入队;
  • 业务态/会话态/连接状态这种"长期可变共享对象"用 actor 收益最大,编译期保护比那点性能值钱得多;
  • actor 不会饿死调用线程(线程会被让出),所以高并发下整体吞吐反而可能比锁更稳。

9. 选型建议

优先用 actor 的场景

  • 长期持有的有状态对象:连接、会话、缓存、数据源、状态机;
  • 需要被多种异步流程并发访问的资源;
  • 想让编译器替你检查并发安全,避免事后被数据竞争 bug 反复折磨。

保留传统锁 / 串行队列的场景

  • 微秒级热路径(解码、像素计算、加密内层循环):用 os_unfair_lock 更划算;
  • 与 OC / C++ 互通:actor 必须包一层桥接才能给同步代码用;
  • 已存在大量同步代码、迁移成本高于收益的旧模块。

混合工程的实践要点

  • 把 actor 暴露给 OC 时,封一层 nonisolated@MainActor 的桥接 facade,在 facade 里用 Task { await actor.xxx() } 启动;
  • @MainActor 标 UI 类,让"主线程访问"由编译器保证;
  • actor 内每个 await 都视作"状态可能变更点",复合操作显式用状态/in-flight 表去重;
  • Swift 6 严格并发开起来——它会逼你把 Sendable 标对,是把这一套真正落地的关键开关。

参考

  • Swift Evolution: SE-0306 Actors
  • Swift Evolution: SE-0316 Global Actors
  • Swift Evolution: SE-0337 Incremental Migration to Concurrency Checking
  • Swift Evolution: SE-0392 Custom Actor Executors
  • WWDC 2021 Protect mutable state with Swift actors
  • WWDC 2022 Eliminate data races using Swift Concurrency
  • Apple, The Swift Programming Language — Concurrency 章节