雲端 Mac 上的 Swift actor 再進入測試:跨 await 配額保留與復原

雲端 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 已隔離狀態,為何仍會超發配額?

方法在 await 處可能讓出執行權。若先檢查容量卻未立即保留名額,另一個呼叫就可能讀到相同的可用容量。

工作取消時應立即釋放名額嗎?

必須先確認底層操作不再使用資源。取消只是協作訊號;操作尚未返回就釋放名額,可能造成同一份容量被重複使用。

為你的工作流程接上專屬 Mac

使用原生 macOS 完成建置、測試與實驗。先比較規格與計費週期,再選擇適合的實體節點。

立即租用雲端 Mac 查看機型方案

把下一次建置交給雲端 Mac

專屬實體機,非虛擬機。依照專案週期選擇規格與租用期間。