舊 SDK 的完成回呼在本機測試時一直正常,移到雲端 Mac 平行執行後,卻偶爾出現 continuation 重複恢復,或是任務取消後遲遲等不到結果。問題通常不在機器效能,而在於轉接器將「回呼只會觸發一次,而且一定非同步到達」視為既定事實。以下移除這兩個假設,用獨立的 Swift 程式驗收邊界行為,不依賴真實網路,也不靠隨機等待。
先定義誰能決定最終結果
本文處理的是一次性操作:成功、失敗、取消三者之中,最先提交至狀態機的結果生效。後續通知不再改變結果,也不能再次恢復等待端。串流或會多次更新的介面不適用這個契約。
需要保存兩類狀態:尚待安裝的 continuation,以及已經確定的最終結果。取消可能早於 continuation 的安裝,因此不能只靠一個可為空值的變數判斷任務是否結束。
checked continuation 會檢查錯誤用法,但不會替你排除重複通知,也不會自動將任務取消轉成底層操作的取消。
這裡採用的是「先提交者生效」,而不是「取消永遠優先」。成功一旦提交,即使恢復動作尚未獲得排程執行,之後到達的取消也不能覆寫成功結果。若業務需求要求更嚴格的取消優先順序,應另行定義提交點,而不是在回傳值外隨意補上檢查。
用短臨界區保護完成狀態
將以下程式碼儲存為 BridgeCheck.swift。範例使用 Swift 6,以及提供 NSLock.withLock 的 macOS SDK。鎖定範圍只涵蓋狀態的讀取與寫入,不包含外部回呼、請求啟動或 continuation 恢復。
import Foundation
final class CompletionGate<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var outcome: Result<Value, any Error>?
private var waiter: CheckedContinuation<Value, any Error>?
func install(_ continuation: CheckedContinuation<Value, any Error>) {
let ready: Result<Value, any Error>? = lock.withLock {
if let outcome {
return outcome
}
precondition(waiter == nil)
waiter = continuation
return nil
}
if let ready {
continuation.resume(with: ready)
}
}
func finish(_ result: Result<Value, any Error>) {
let continuation: CheckedContinuation<Value, any Error>? =
lock.withLock {
guard outcome == nil else {
return nil
}
outcome = result
let current = waiter
waiter = nil
return current
}
continuation?.resume(with: result)
}
}
func bridge(
start: @Sendable (
@escaping @Sendable (Result<Int, any Error>) -> Void
) -> Void
) async throws -> Int {
let gate = CompletionGate<Int>()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Int, any Error>) in
gate.install(continuation)
start { result in
gate.finish(result)
}
}
} onCancel: {
gate.finish(.failure(CancellationError()))
}
}
@unchecked Sendable 並不是關閉檢查後就可以置之不理。它代表維護者承諾:所有共用的可變欄位都由同一把鎖保護,結果值也符合 Sendable。日後若新增計數器或控制代碼,同樣必須審查其同步方式。
安裝與完成之間只有兩種先後順序:先安裝,就取出等待端並恢復;先完成,就保存結果,等安裝時再恢復。清空等待端可及時解除參照,但判斷「是否結束」的依據始終是結果狀態,不能把清空誤認為重新開放接受結果。
用確定性範例涵蓋關鍵順序
將以下進入點附加到同一個檔案。第一項刻意同步觸發兩次回呼;第二項啟動一個永遠不會回呼的操作,接著取消等待端。整個過程都不靠休眠猜測排程時機。
@main
struct BridgeCheck {
static func main() async throws {
let value = try await bridge { complete in
complete(.success(7))
complete(.success(9))
}
precondition(value == 7)
let pending = Task {
try await bridge { _ in }
}
pending.cancel()
do {
_ = try await pending.value
preconditionFailure("Expected cancellation")
} catch is CancellationError {
}
}
}
xcrun swiftc -parse-as-library -swift-version 6 \
BridgeCheck.swift -o BridgeCheck
./BridgeCheck
正常執行應以結束代碼零結束。同步完成案例能找出「先啟動、後儲存 continuation」的錯誤實作;重複通知案例能找出未經保護的第二次恢復;取消案例則用來確認,在沒有回呼的情況下,等待端是否仍能結束。
納入專案測試後,還應直接測試狀態機「先完成、後安裝」的情況,並分別以成功先於失敗、失敗先於成功的順序各跑一次。並行壓力測試可以補足涵蓋範圍,但不能取代這些明確指定順序的案例。
取消等待不代表停止底層操作
這個轉接器保證等待端會結束,卻不會停止底層工作。若取消先到,start 仍可能執行,之後的通知則會被忽略。這是刻意保留的責任邊界,不應將範例直接當成完整的網路用戶端。
有取消控制代碼時如何擴充
如果實際介面會回傳取消控制代碼,就需要將它登錄在同一份受保護的狀態中。取消先到時,先記錄旗標;稍後控制代碼回傳時,立即取出並取消。控制代碼先到時,則由取消路徑取出。實際取消動作仍須在鎖外執行,因為它可能同步觸發完成回呼。
不要只在啟動前檢查一次取消狀態。檢查通過到控制代碼回傳之間,仍然存在競態。也不要在鎖內呼叫取消介面,否則同步回呼再次進入狀態機時,可能造成死結。
若底層永遠不通知,呼叫端也不取消,等待就會持續。業務逾時必須走同一個完成入口,並同時處理底層資源回收;不能假設 continuation 內建逾時機制。
將驗收納入雲端執行入口
在雲端 Mac 的建置前置階段編譯並執行這份檢查,記錄編譯器版本、提交識別碼與結束代碼。範例不需要圖形介面工作階段,適合透過命令列反覆執行。
合併前核對四項:每次呼叫都有獨立的狀態機;同步回呼不會遺失;重複通知不會重複恢復;取消後沒有等待端持續懸置。對於帶有控制代碼的實際介面,再補上「取消早於控制代碼回傳」的專門測試。
最後檢查閉包之間的持有關係。即使等待已經結束,底層仍可能持有完成閉包;狀態機排除重複通知,並不能解決所有生命週期問題。先分別驗收完成契約、停止契約與資源釋放,非同步包裝才算真正完整。
常見問題
checked continuation 會自動忽略第二次回呼嗎?
不會。重複 resume 違反使用契約,可能觸發執行期檢查。應先透過同步保護的狀態機選出唯一結果,再於鎖外恢復 continuation。
取消工作是否也會停止底層請求?
不會自動停止。此轉接器只讓等待方以取消錯誤結束;底層操作必須使用自己的取消介面,並處理取消早於操作控制代碼回傳的情況。
將下一次建置交給獨享雲端 Mac
MacFTP 提供搭載原生 macOS 的 Apple Silicon 獨享實體主機,可選擇日租、週租、月租或季租。先確認規格,再將現有工具鏈連接至節點;實際可用狀態請以控制台即時資訊為準。