Swift Actor 为什么选择可重入设计?——一道让人深思的并发题
Swift Actor 为什么选择可重入设计?——一道让人深思的并发题
iOS 进阶必修 · Swift 并发编程系列 第 2 期
面试官问你:"Swift 的 actor 是可重入的,你觉得这个设计合理吗?"
很多人第一反应是:可重入?那不是有 bug 风险吗?为什么不做成传统锁那样不可重入?
这篇文章就来彻底说清楚这件事。
先把概念说明白
可重入(Reentrant):actor 在 await 挂起时会释放自身的"访问权",其他任务可以趁机进入 actor 执行别的方法。
不可重入(Non-reentrant):actor 一旦被某任务占用,其他任务必须排队等待,直到当前任务彻底执行完(包括所有 await)。
用一句话概括差异:
可重入:
await是"暂时离开",锁被放开
不可重入:await是"原地等待",锁被一直握着
如果 Actor 是不可重入的,会发生什么?
死锁:跨 actor 调用的必然结局
actor ServiceA {
let b: ServiceB
func doWork() async {
await b.help() // A 持锁,等待 B
}
}
actor ServiceB {
let a: ServiceA
func help() async {
await a.check() // B 持锁,等待 A ← 死锁!
}
}
两个 actor 互相持锁等待对方,经典死锁。
在真实业务里这种结构比比皆是——网络层调用缓存层,缓存层调用配置层,配置层又依赖某个共享状态…只要存在环形调用,就必然死锁。
而且这种死锁极难排查:没有崩溃日志,没有报错,App 就静静地卡在那里。
Actor 内部 async 调用,自己等自己
actor Logger {
func log(_ msg: String) async {
await writeToFile(msg) // 不可重入 → 自己等自己 → 死锁
}
func writeToFile(_ msg: String) async {
// 磁盘写入…
}
}
这意味着什么?actor 内部完全不能出现 await。但现实中 actor 管理的资源(网络、磁盘、数据库)几乎无一例外需要异步操作。
不可重入 + async/await 生态,在逻辑上根本无法自洽。
那可重入会带来哪些坑?
可重入把死锁风险消除了,但代价是:await 挂起期间,actor 的状态可能被其他任务修改。
坑 1:状态假设在 await 前后失效
这是最经典的重入陷阱,银行转账场景:
actor BankAccount {
var balance: Double = 1000
func withdraw(_ amount: Double) async throws {
// ① 检查余额:1000 >= 800,通过
guard balance >= amount else { throw InsufficientFundsError() }
// ② await 挂起,actor 释放访问权
// 另一个 withdraw(800) 趁机进来,也通过了 guard
// 它先执行,balance 变成 200
await logTransaction(amount)
// ③ 回来继续执行:800 > 200,但已经没有再次检查!
balance -= amount // balance = 200 - 800 = -600,超支!
}
}
// 并发:两个任务同时取 800
Task { try await account.withdraw(800) }
Task { try await account.withdraw(800) }
// 最终 balance = -600,资损!
问题的根源:guard 检查到 balance -= amount 之间夹着一个 await,整个操作不是原子的。
坑 2:不变量(Invariant)在 await 期间被破坏
actor DataPipeline {
var isProcessing = false
var buffer: [Data] = []
func process() async {
guard !isProcessing else { return }
isProcessing = true // 设置标志
// await 挂起,另一个 process() 调用进来
// 它看到 isProcessing = true,直接 return
// 看起来没问题…但如果两个调用"同时"通过 guard 呢?
// → 取决于调度时序,存在 TOCTOU(检查-使用时差)窗口
await doHeavyWork()
isProcessing = false
}
}
正确应对可重入的三个模式
模式一:await 之前完成所有关键状态变更
actor BankAccount {
var balance: Double = 1000
// ✅ 正确写法
func withdraw(_ amount: Double) async throws {
guard balance >= amount else { throw InsufficientFundsError() }
balance -= amount // ← 先改状态(无 await,绝对原子)
await logTransaction(amount) // 再异步处理(状态已一致)
}
}
规则:guard 检查通过后,立刻完成状态变更,然后才 await。await 之后不再依赖之前检查过的条件。
模式二:原子卫兵——同步方法作为临界区
actor SafeQueue {
private var items: [WorkItem] = []
private var isRunning = false
// 同步方法:无 await,绝对原子
private func takeNext() -> WorkItem? {
guard let item = items.first else { return nil }
items.removeFirst() // 取出即删除,不会被重入影响
return item
}
func drainAll() async {
guard !isRunning else { return }
isRunning = true
while let item = takeNext() {
await item.execute() // await 时 item 已从队列移除,安全
}
isRunning = false
}
}
思路:把"检查 + 修改"合并进一个不含 await 的同步方法,让它成为原子操作。
模式三:状态机保护并发入口
actor TaskScheduler {
private enum Phase { case idle, running, draining }
private var phase: Phase = .idle
func schedule(_ task: Task<Void, Never>) async {
guard phase == .idle else { return }
phase = .running // ← await 之前切状态,拿到"令牌"
await task.value // 其他调用看到 .running,直接 return
phase = .idle
}
}
用状态机枚举而非 Bool 标志,让每种状态的含义更清晰,也更难被误用。
设计对比:可重入 vs 不可重入
| 维度 | 不可重入(传统锁语义) | 可重入(Swift actor) |
|---|---|---|
| 跨 actor 调用 | ❌ 极易死锁 | ✅ 安全 |
| actor 内部 await | ❌ 自己等自己,死锁 | ✅ 正常工作 |
| 状态一致性 | await 前后一致 | ⚠️ 开发者自行保证 |
| 死锁风险 | ❌ 高,且难排查 | ✅ 无 |
| 正确性复杂度 | 低(锁语义直觉) | 中(需理解挂起语义) |
| 与 async/await 生态兼容性 | ❌ 根本无法自洽 | ✅ 天然融合 |
Apple 为什么必须选可重入
这是一道"两害取其轻"的工程决策题:
- 死锁:不可预测,运行时无日志,难以复现,线上问题几乎无法定位
- 重入陷阱:有规律可循(await 前完成状态变更),编码期可发现,有明确的防御模式
Apple 把不确定性更高、危害更大的风险消除了,把相对可控的复杂性留给开发者。
从语言设计角度看,这也与 Swift 的一贯哲学吻合:编译器负责能静态验证的安全,开发者负责剩下的语义正确性。
Swift 6 的严格并发检查(
-strict-concurrency=complete)正在把越来越多的重入问题提升为编译器警告,方向是对的。
实际项目中的选择建议
优先用可重入,配合以下纪律:
-
黄金法则:
await之前必须完成所有关键状态变更,await之后不再信任之前读取的条件 -
原子临界区:把"检查 + 修改"封装进无
await的同步方法 - 状态机优先:用枚举状态机而非 Bool 标志管理并发入口
-
最小化 await 范围:需要保护的临界操作不要夹带
await
// 完整示例:安全的资源管理 actor
actor ResourceManager {
private enum State { case idle, acquired, releasing }
private var state: State = .idle
private var resource: Resource?
// ✅ 获取资源:先拿到"凭证"再 await
func acquire() async throws -> Resource {
guard state == .idle else { throw ResourceError.busy }
state = .acquired // 改状态在 await 之前
let res = try await fetchResource()
resource = res
return res
}
// ✅ 释放资源:先清理状态再 await
func release() async {
guard state == .acquired else { return }
let res = resource
resource = nil // 先清空
state = .releasing
await cleanupResource(res)
state = .idle
}
}
总结
| 问题 | 答案 |
|---|---|
| 可重入设计合理吗? | 合理,是工程必要性决定的,不是妥协 |
| 不可重入的最大问题? | 跨 actor 死锁 + 内部 async 调用死锁,且难排查 |
| 可重入最大的坑? | await 前后状态假设失效,经典场景是 guard 通过后 await,回来状态已变 |
| 实际项目怎么用? | 拥抱可重入,用"await 前完成状态变更"作为硬性编码纪律 |
可重入的坑有规律可循,死锁没有。选可重入,然后学会驾驭它。
延伸思考
Kotlin 协程的 Mutex 提供了不可重入的互斥锁,但它是手动使用的工具,而不是语言默认行为——与 Swift actor 的定位完全不同。Java 的 synchronized 则是可重入的(同一线程可以重复进入),与 Swift actor 的可重入语义有些类似,但实现机制不同。
Swift actor 的可重入设计,本质上是结构化并发思想的延伸:任务在 await 时让出资源,让其他任务有机会推进,整个系统的吞吐量更高,而不是让一个任务独占 actor 直到它的所有 await 全部完成。
如果你在项目里遇到过 actor 重入导致的 bug,欢迎评论区分享——是什么场景、如何发现、怎么修复的?优质案例会收录进下一期。
📅 本系列持续更新 ✅ 第 1 期:Swift Concurrency 基础精讲 · ➡️ 第 2 期:Actor 可重入设计深析(本期)· ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定