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

33 阅读4分钟

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

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

典型后果包括:

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

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


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

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

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

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

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

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 实现(源码)

//
//  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:仍可能出现交错

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

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),例如:

[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 里:

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 等价的类型,可直接对照实现。