Swift Actor 为什么选择可重入设计?——一道让人深思的并发题

3 阅读7分钟

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 检查通过后,立刻完成状态变更,然后才 awaitawait 之后不再依赖之前检查过的条件。


模式二:原子卫兵——同步方法作为临界区

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)正在把越来越多的重入问题提升为编译器警告,方向是对的。


实际项目中的选择建议

优先用可重入,配合以下纪律:

  1. 黄金法则await 之前必须完成所有关键状态变更,await 之后不再信任之前读取的条件
  2. 原子临界区:把"检查 + 修改"封装进无 await 的同步方法
  3. 状态机优先:用枚举状态机而非 Bool 标志管理并发入口
  4. 最小化 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 期:待定