Skip to content

Swift Actor 隔离与可重入性工程化作战图

7 min read

Swift 团队最容易误判的一点是:“用了 actor,就等于业务状态安全”。这句话只对了一半。 Actor 解决的是同一时刻的可变状态竞争,不等于跨 await 的事务一致性。只要方法中存在挂起点,就会发生可重入,旧任务恢复时看到的上下文可能已被其他任务改写。生产事故往往正是从这里冒出来。

一、先把边界讲透:Actor 到底保证了什么

Actor 的核心保证是执行器串行化:同一 actor 的隔离状态在任一瞬间只会被一个执行片段访问。 这能有效降低传统锁模型里的竞态和死锁概率,但它并不提供数据库式事务隔离级别。

很多项目踩坑,通常来自三个错误假设:

  1. 认为一个 async actor 方法从头到尾是“原子执行”。
  2. 认为 await 只会让出 CPU,不会让出状态时序控制权。
  3. 认为线程安全等同于业务不变量安全。

看一个最典型的错误模型:

actor Wallet {
    private var balance: Int = 100
    private var version: Int = 0

    func withdraw(_ amount: Int, gateway: Gateway) async throws {
        guard balance >= amount else { throw WalletError.insufficient }
        let snapshotVersion = version

        try await gateway.reserve(amount) // 这里发生挂起,可重入

        guard snapshotVersion == version else {
            throw WalletError.staleState
        }
        balance -= amount
        version += 1
    }
}

如果没有 snapshotVersion 校验,恢复后直接减余额,就可能覆盖掉其他并发操作已经写入的新状态。 这不是 Swift 的 bug,而是执行模型本来如此。工程上必须主动设计“恢复后再确认”的防线。

二、可重入时序图:为什么逻辑会被插队

sequenceDiagram
    participant T1 as Task-1(提现100)
    participant A as WalletActor
    participant T2 as Task-2(退款80)
    participant G as Gateway

    T1->>A: withdraw(100) 入队
    A->>A: 读取 balance=200
    A->>G: await reserve(100)
    Note over A: T1 挂起, Actor 可处理其他消息
    T2->>A: refund(80) 入队并执行
    A->>A: balance=280, version+1
    G-->>A: reserve 完成
    A->>A: T1 恢复, 发现 version 改变
    A-->>T1: 抛出 staleState 或重试

这个时序本质说明了两件事:

  1. Actor 串行不等于请求顺序永远线性,挂起点会打断“思维中的顺序”。
  2. 你在 await 之前做的条件判断,恢复后可能已经过期。

三、架构落地:把“逻辑正确”拆成四层防线

1)模型层:显式状态机,不用散落布尔值

支付、库存、订单一类领域建议强制用状态机建模,例如 created -> reserved -> committed -> rolledBack。 每次迁移写成“前置条件 + 迁移动作 + 失败策略”的显式函数,禁止在多个方法里随意改字段。

收益是可重入窗口变窄:恢复后只要前置条件不成立,立即失败或重试,不会悄悄写坏状态。

2)隔离层:小 Actor + 聚合根,避免超级 Actor

很多团队第一版喜欢把大量域对象塞进一个 StateStoreActor,短期看省事,长期会出现:

  1. 热点争用,吞吐下降。
  2. 一个模块的重计算拖慢全局消息队列。
  3. 排障时责任边界不清晰。

更稳妥的方式是按聚合根拆 Actor:OrderActorInventoryActorPricingActor。 跨聚合通讯用 Sendable 值对象,减少共享引用对象在多个并发域逃逸。

3)持久层:幂等键 + 版本戳,兜底跨进程竞争

Actor 只保护进程内状态,不保护跨进程、跨副本、跨重试。 关键命令必须带幂等键(如 operationId),数据库更新使用版本戳或条件更新:

UPDATE wallet
SET balance = balance - :amount, version = version + 1
WHERE user_id = :id AND version = :expectedVersion;

受影响行数为 0 就是并发冲突,不继续“盲写”。 这是把可重入风险从应用层再向下压一层的关键手段。

4)调用层:两段式流程,缩短一致性窗口

推荐模式:

  1. 第一段在 actor 内做轻量校验,生成“预提交意图”。
  2. 第二段在 actor 外做 I/O(网络、磁盘、第三方调用)。
  3. 回到 actor 进行提交前再校验,最后落盘。

这种模式不会消灭可重入,但能将“脆弱窗口”从整段函数压缩到几个可验证点。

四、代码组织:哪些 API 应该 nonisolated,哪些不该

nonisolated 的用途是减少不必要 hop,但它只适合不读写隔离可变状态的逻辑。 常见正确用法:

  1. 纯计算工具函数。
  2. 常量元数据访问。
  3. 对外协议实现中的轻量适配层。

常见误用:

  1. nonisolated 中间接调用读写状态的方法。
  2. 通过引用类型属性泄露内部状态。
  3. 为了“性能优化”绕过隔离,结果把数据竞争引回来了。

建议在 Code Review 中加一条硬规则:凡新增 nonisolated,必须附上“为何不访问隔离状态”的说明和测试。

五、风险清单:最容易在上线后爆的 8 类问题

  1. await 前后缺少版本校验,出现旧任务覆盖新状态。
  2. 使用可变引用类型跨任务传递,未满足 Sendable 约束。
  3. 取消信号未传播,导致半完成副作用遗留。
  4. actor 内执行重 CPU 任务,消息堆积造成长尾延迟。
  5. 过度跨 actor hop,链路延迟被切换成本放大。
  6. @MainActor 使用不当,把后台重活拉回主线程。
  7. 错误恢复路径只回滚内存状态,未回滚外部系统。
  8. 缺少结构化日志,事故后无法还原时序。

这 8 项建议直接变成发布门禁 Checklist,而不是文档建议。

六、性能调优:不是“多并发”,而是“少无效切换”

Actor 相关性能优化建议按证据做,不要凭感觉:

  1. 用 Instruments 的 Swift Concurrency 模板观察任务调度与 actor hop。
  2. 对高频短调用做批处理 API,减少每次 hop 的固定开销。
  3. 对热点状态设计只读快照,避免每次都进入 actor 查询。
  4. 明确 I/O 与计算职责,避免 actor 里等待慢网络。

可执行的预算示例:

  • 单次用户操作允许的 actor hop 次数上限:<= 8
  • 关键 actor 队列等待 P95:<= 20ms
  • 关键流程恢复后重试率:<= 1%

超过预算后优先做架构调整,而不是继续微调语法。

七、测试策略:把可重入缺陷“制造出来”

并发 bug 不会因为写了单元测试就消失,必须用“故障放大”的方式测试。

1)挂起点注入测试

在关键 await 前后注入可控延迟,扩大插队概率,让偶发问题变成高概率问题。

2)并发交错测试

同一实体上并发执行冲突操作(提现、退款、冻结、解冻),验证最终不变量:

  1. 余额不为负。
  2. 版本号单调递增。
  3. 订单状态迁移合法。

3)取消与超时测试

在外部 I/O 超时、上游取消、网络抖动场景下,验证:

  1. 取消能向下游传播。
  2. 已分配资源被回收。
  3. 半提交状态可补偿或可重试。

4)回归压测

每次修改并发核心模块后,固定 seed 运行高并发压力脚本,比较冲突率、重试率、P99 延迟。 没有量化回归对比,任何“我感觉更快更稳”都不算结论。

八、线上排障流程:四步还原真实故障链

flowchart TD
    A[告警触发: 余额异常或状态回退] --> B[聚合日志按 entityId 与 requestId]
    B --> C[重建时序: await 前后版本变化]
    C --> D{是否发生可重入覆盖}
    D -- 是 --> E[补版本校验与幂等键]
    D -- 否 --> F[检查跨进程重复投递或重试风暴]
    E --> G[并发回归加压测门禁]
    F --> G

重点不是“马上改代码”,而是先恢复证据链。 没有时序证据就修复,极容易把一个问题改成两个问题。

九、团队协作约束:把并发规范写进流程,而不是口头约定

推荐在工程流程里固化以下规则:

  1. 并发核心模块必须有设计说明,列出隔离边界与不变量。
  2. 每个 await 旁边要么证明安全,要么给出恢复后校验。
  3. 每次合并必须通过并发回归套件,不允许仅跑 happy path。
  4. 发布前输出“冲突率、重试率、取消生效率”三项报告。
  5. 事故复盘要产出可自动检查的规则,进入 lint 或 review 模板。

这样做的价值是:把个人经验沉淀成组织能力,减少“某位专家离开就没人敢改并发代码”的风险。

十、结语

Actor 的真正价值不是“写法更现代”,而是给了你一套可验证的并发语义。 如果只停留在语法层,生产事故仍会出现;如果把状态机、版本戳、幂等、可观测、回归门禁一起落地,Actor 才会真正成为稳定性资产。

最终目标很明确: 不是“没有并发 bug”这种不现实目标,而是“出现问题可定位、可回滚、可防复发”。