雲端 Mac 上的 Swift 回呼非同步轉接驗收:重複完成與取消競態

雲端 Mac 上的 Swift 回呼非同步轉接驗收:重複完成與取消競態

舊 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 獨享實體主機,可選擇日租、週租、月租或季租。先確認規格,再將現有工具鏈連接至節點;實際可用狀態請以控制台即時資訊為準。

立即租用雲端 Mac

從閱讀回到專案

先確認規格,再安排交付

比較機型與租期,或回到部落格繼續閱讀技術文章。