云端 Mac 上的 Swift actor 重入验收:跨 await 配额预留与回滚

云端 Mac 上的 Swift actor 重入验收:跨 await 配额预留与回滚

云端 Mac 上的两个导出请求,明明经过同一个 actor 检查,却都拿到了最后一个名额。问题不一定是数据竞争:第一个请求检查容量后等待异步准备,第二个请求就在这段空隙里进入。你要验收的是跨 await 的业务约束,而不是 actor 能否串行读写变量。

先把容量约束写清楚

假设一个导出服务最多允许一个任务占用临时工作区。容量包含“正在准备”和“正在执行”,不能只统计已经完成准备的任务。否则,慢速准备会变成绕过限制的入口。

危险顺序是“检查空位、等待准备、增加占用”。等待期间 actor 可以处理其他调用,因此检查结果不再代表恢复执行时的状态。正确顺序是先在不含悬挂点的代码段里完成检查与预留,再执行异步操作。

actor 隔离保护状态访问,不会把整个异步方法变成不可打断的事务。每个 await 都应检查:恢复时,我依赖的条件是否仍然成立?

本例选择容量满时立即拒绝,不建立等待队列。排队、公平性和超时属于另一份契约,不能靠调用者无限重试补上。

用令牌表达预留的所有权

在云端 Mac 新建名为 ActorQuota 的 macOS 工程,添加 XCTest 单元测试目标。把下面代码放进测试文件,替换默认示例。这里验证的是共享 Swift 并发逻辑,不需要启动 iOS 模拟器。

import Foundation
import XCTest

enum QuotaError: Error {
    case full
}

actor ReservationPool {
    private let capacity: Int
    private var active: Set<UUID> = []

    init(capacity: Int) {
        precondition(capacity > 0)
        self.capacity = capacity
    }

    var count: Int { active.count }

    func run(
        _ operation: @Sendable () async throws -> Void
    ) async throws {
        try Task.checkCancellation()
        guard active.count < capacity else {
            throw QuotaError.full
        }

        let token = UUID()
        active.insert(token)
        defer { active.remove(token) }

        try await operation()
        try Task.checkCancellation()
    }
}

检查与插入之间没有 await,其他调用无法在中间插入。每次调用持有自己的令牌,退出时只释放自己的预留,避免失败分支误减别人的占用。

defer 覆盖正常返回和抛错,但它不是资源管理的万能替代。传入操作必须等到实际工作结束才返回;如果它启动一个脱离当前生命周期的任务后立即返回,名额仍会提前释放。调用约定要和实现一起评审。

固定重入时序,不靠睡眠碰运气

给第一个操作放一个可控暂停点。第二次调用必须发生在预留完成之后、释放之前。固定睡眠只能猜测执行进度,机器负载变化后就会出现偶发通过。

继续把这个一次性夹具和测试类放进同一文件。夹具允许先打开再等待,避免信号先到导致永久挂起。

actor PauseGate {
    private var opened = false
    private var waiter: CheckedContinuation<Void, Never>?

    func pause() async {
        if opened { return }
        await withCheckedContinuation { continuation in
            waiter = continuation
        }
    }

    func open() {
        opened = true
        waiter?.resume()
        waiter = nil
    }
}

final class ReservationTests: XCTestCase, @unchecked Sendable {
    func testRejectsOverlapAndReleases() async throws {
        let pool = ReservationPool(capacity: 1)
        let gate = PauseGate()
        let entered = expectation(description: "Operation entered")

        let first = Task {
            try await pool.run {
                entered.fulfill()
                await gate.pause()
            }
        }

        await fulfillment(of: [entered], timeout: 2.0)

        let during = await pool.count
        XCTAssertEqual(during, 1)

        do {
            try await pool.run {}
            XCTFail("Overlapping operation was accepted")
        } catch QuotaError.full {
        }

        await gate.open()
        try await first.value

        let after = await pool.count
        XCTAssertEqual(after, 0)

        try await pool.run {}
    }
}

期望被满足,说明第一个调用已经进入操作体,预留必然存在。两秒是等待夹具到达的超时上限,不是性能标准。夹具只支持一个等待者;不要把它直接当成生产环境的广播组件。

分开验收失败与取消

新增抛错用例:让操作立即抛出测试专用错误,捕获并核对错误类型,然后读取 count。结果必须为零,下一次调用也必须成功。只断言错误存在,会漏掉“业务失败但名额泄漏”。

取消用例沿用暂停点:等第一个操作进入后调用 first.cancel(),此时读取占用,仍应为一。随后打开夹具,再等待任务结果,应收到 CancellationError,最终占用归零。

这是有意保守的语义。取消只发出协作信号,本例的暂停夹具不会因取消自动恢复。底层操作尚未退出就释放容量,会让新任务与旧任务重叠。

若真实导出接口支持取消,应把取消信号传进去,并等待它确认结束。若操作已经产生不可逆副作用,末尾的取消检查也不能撤销结果;需要另行定义提交点及结果查询方式,不能把“抛出取消”解释成“什么都没发生”。

接入构建并保留反例

在工程根目录执行以下命令。工程名和共享 Scheme 按前面的创建步骤保持一致;测试类放在 ActorQuotaTests 目标中。

set -euo pipefail
xcodebuild \
  -project ActorQuota.xcodeproj \
  -scheme ActorQuota \
  -destination 'platform=macOS' \
  -only-testing:ActorQuotaTests/ReservationTests \
  test

先保留正确实现的通过结果,再在临时分支故意把预留移动到异步操作之后。重叠测试应失败;如果仍通过,说明测试没有真正覆盖检查与占用之间的空隙。

最终检查四件事:等待前已有预留,拒绝调用不改变占用,异常只释放自己的令牌,取消后实际操作退出才归还容量。再把容量改成二,暂停两个操作并拒绝第三个,验证实现没有把限制写死成布尔开关。

把这组测试跟随资源池实现提交。以后重构异步准备流程时,它比一句“这里用了 actor,所以安全”更能保护真实的容量边界。

常见问题

actor 已经隔离状态,为什么还会超发配额?

actor 方法在 await 处可能让出执行权。若只检查容量却没有立即记录预留,另一个调用会看到同样的可用容量,因此业务约束仍可能被破坏。

任务取消后应该立即释放预留吗?

只有底层操作已停止占用资源时才释放。取消是协作信号;若异步操作尚未返回,提前释放可能让两个操作同时使用同一份容量。

给你的工作流接一台独享 Mac

用原生 macOS 完成构建、测试与实验。先比较配置和计费周期,再选择适合的物理节点。

立即租用云端 Mac 查看机型方案

把下一次构建交给云端 Mac

独享物理机,非虚拟机。按你的项目周期选择配置与租期。