一个小工具解决Swift Actor重入问题

问题:Actor 不是「一进到底」

Swift 的 Actor 能保证同一时刻只有一个任务在隔离域里执行 ,但在 await 挂起 时,Actor 会释放执行权,其他发往该 Actor 的调用可以继续执行。这就是常见的 Actor 重入(reentrancy):你以为「上一条逻辑还没跑完」,实际上中间已经插入了别的消息处理。

典型后果包括:

  • 生命周期或状态机类 API(初始化 → 运行 → 销毁)被交错执行;
  • 共享资源在「以为仍独占」时被另一条路径修改;
  • 调试时表现为顺序与代码书写顺序不一致

若业务语义要求「前一次异步流程整段结束 (包含其内部所有 await)后,再开始下一次」,仅靠 Actor 的默认调度是不够的,需要显式串行化


重入:用打印顺序看清「中间插了一刀」

下面这个 Actor 有两个异步方法,内部都有一次 await Task.yield()(可换成任何真正的异步点):

swift 复制代码
import Foundation

actor InterleavingDemo {
    func taskA() async {
        print("A: step 1")
        await Task.yield()
        print("A: step 2")
    }

    func taskB() async {
        print("B: only step")
    }
}

从外部几乎同时 发起 taskAtaskB

swift 复制代码
let demo = InterleavingDemo()
await withTaskGroup(of: Void.self) { group in
    group.addTask { await demo.taskA() }
    group.addTask { await demo.taskB() }
}

可能出现的输出之一(重入):

text 复制代码
A: step 1
B: only step
A: step 2

含义很具体:taskA 在第一个 await 挂起后,taskB 整段插进来跑完 ,然后 taskA 才继续。若这里维护的是「会话 / 引擎生命周期」或「依赖连续不变量的状态机」,这种交错往往就是 bug 来源。


思路:用队尾屏障把异步工作连成 FIFO 链

串行异步门闩AASerialAsyncGate)的核心思想是:

  1. 维护一个 tailBarrier:表示「当前队列里,排在队尾之前的那条链何时算全部结束」;
  2. 每次 run 时,新任务必须先 await 前一个屏障 ,再执行自己的 operation
  3. 执行完毕后更新屏障,让后续任务继续排队。

这样,同一时刻逻辑上只有一条链在执行 ,新调用不会与前序调用的 await 间隙「插队」到业务语义的前面。


AASerialAsyncGate 实现(源码)

swift 复制代码
//
//  AASerialAsyncGate.swift
//
//  串行异步任务队列:每次 `run` 将闭包入队到队尾;新任务会等待此前入队的全部任务
//  整段完成(含其内部所有 await)后才开始执行,执行完毕自动出队(屏障前移)。
//  从而避免宿主 Swift Actor 在 await 处重入时,多条生命周期调用交错执行。
//

import Foundation

public final class AASerialAsyncGate: @unchecked Sendable {
    /// 队尾屏障:完成即表示当前队列中此前所有任务均已结束;新任务必须先 `await` 再执行自身逻辑。
    private var tailBarrier: Task<Void, Never> = Task {}

    public init() {}

    /// 将 `operation` 入队;前序任务全部结束后才执行;同一时刻逻辑上仅一条链在执行。
    /// 统一为 `async throws`:`Task.value` 的 Failure 与 `rethrows` 不兼容,故不用 `rethrows`。
    /// 闭包本身不抛错时,宿主侧可用 `try? await gate.run { ... }`。
    public func run<T: Sendable>(
        _ operation: @escaping @Sendable () async throws -> T
    ) async throws -> T {
        let work: Task<T, Error>
        let predecessor = tailBarrier
        work = Task {
            await predecessor.value
            return try await operation()
        }
        tailBarrier = Task {
            _ = try? await work.value
        }
        return try await work.value
    }
}

要点:后来的 run 必须先等「前一个 tailBarrier 代表的整段 operation(含内部所有 await)结束」 ,因此同一 gate 上的多段逻辑在时间上不会 在彼此的 await 缝隙里交错。

为何统一为 async throws

Task.valueFailurerethrows 在类型系统上不易直接对齐,故统一为 async throws;若闭包本身不抛错,调用侧可用 try? await gate.run { ... }


验证示例:对比「仅 Actor」与「Actor + Gate」

1. 仅 Actor:仍可能出现交错

swift 复制代码
actor EngineWithoutGate {
    private let name: String

    init(name: String) { self.name = name }

    func work(_ label: String) async {
        print("[\(name)] \(label) --- before await")
        await Task.yield()
        print("[\(name)] \(label) --- after await")
    }
}

func demoActorOnly() async {
    let engine = EngineWithoutGate(name: "E1")
    await withTaskGroup(of: Void.self) { group in
        group.addTask { await engine.work("call-1") }
        group.addTask { await engine.work("call-2") }
    }
}

多次运行或依赖调度,有机会看到 call-2 的整段插在 call-1 的 before/after 之间,这就是要防的重入现象。

2. 加上 AASerialAsyncGate:同一 gate 上严格 FIFO

swift 复制代码
actor EngineWithGate {
    private let name: String
    private let gate = AASerialAsyncGate()

    init(name: String) { self.name = name }

    func work(_ label: String) async {
        try? await gate.run {
            print("[\(name)] \(label) --- before await")
            await Task.yield()
            print("[\(name)] \(label) --- after await")
        }
    }
}

func demoWithGate() async {
    let engine = EngineWithGate(name: "E2")
    await withTaskGroup(of: Void.self) { group in
        group.addTask { await engine.work("call-1") }
        group.addTask { await engine.work("call-2") }
    }
}

稳定期望 (两段 work 都经同一 gate 排队时):先完整跑完 call-1(before → after),再跑 call-2(before → after),例如:

text 复制代码
[E2] call-1 --- before await
[E2] call-1 --- after await
[E2] call-2 --- before await
[E2] call-2 --- after await

可将 print 换成收集到 [String] 的回调,在 XCTest 中断言顺序,作为自动化验证。

3. 宿主是 Actor 时的典型用法

Actor 仍会在自己的方法之间重入;门闩只保证「放进 gate.run 里的那几段」彼此不穿插 。生命周期 API 可全部包在 lifecycleGate.run 里:

swift 复制代码
actor Service {
    private let lifecycleGate = AASerialAsyncGate()

    func startSession() async {
        try? await lifecycleGate.run {
            await connect()
            await configure()
        }
    }

    func stopSession() async {
        try? await lifecycleGate.run {
            await teardown()
        }
    }

    private func connect() async { await Task.yield() }
    private func configure() async { await Task.yield() }
    private func teardown() async { await Task.yield() }
}

小结

场景 说明
重入例子 同一 Actor 上两个 async 方法并发调用时,await 后可能打印出 A1 → B → A2
门闩行为 AASerialAsyncGatetailBarrier 链式 Task,保证后一次 run 等前一次整段结束。
代价 额外 Task 调度与内存;只适合「必须严格串行」的路径。

工程内若存在与 AASerialAsyncGate 等价的类型,可直接对照实现。

相关推荐
Getflare6 小时前
前端 + UI 设计 + AI:这不是三个工种,是一个新三角能力模型(附自检清单)
前端·人工智能·ui
oil欧哟6 小时前
我做了一个 Vibe Coding 术语学习站:VibeHub
前端·ai·agent·独立开发·vibe coding
审小匠OpenCPAi7 小时前
银行流水核查怎么自动化?单边匹配、双向勾稽与图聚类异常检测的工程对比
java·前端·人工智能·python·审计
Revolution617 小时前
一个公共表格组件,是怎么一步步失控的
前端·前端工程化
腻害兔8 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:CRM 客户关系模块深度解析——从线索到回款,一套完整的 B2B 销售闭环是怎么搭的?
java·前端·javascript·vue.js·产品经理·ai编程
whyfail8 小时前
前端学 Spring Boot(2):一次点击,如何穿过整个后端?
前端·spring boot·后端
AI多Agent协作实战派8 小时前
AI多Agent协作系统实战(二十三):Agent读HEARTBEAT.md不读AGENTS.md——openclaw的文件加载之谜
前端·数据库·人工智能·uni-app
2601_955760079 小时前
如何用 Claude Opus 5 API 批量扩展长尾关键词和文章选题
前端·python·搜索引擎
KaMeidebaby9 小时前
卡梅德生物技术快报|bli亲和力检测gst:告别批量跑胶:BLI实时酶切监测技术加速GST融合蛋白下游流程优化
前端·网络·数据库·人工智能·算法
触底反弹10 小时前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js