雲端 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 完成建置、測試與實驗。先比較規格與計費週期,再選擇適合的實體節點。