若要確保 Swift 延續恰好恢復一次,請讓完成與取消透過同一個同步狀態機競爭。即使在安裝前發生取消,也要記錄下來、選定一個終止結果,並在恢復等待中的工作之前釋放鎖。

本教學會為 Mac 命令列程式建立一個本機的回呼轉非同步介面。範例中的合成計算會將整數加倍;不會使用網路呼叫、記錄或持久化儲存。核心契約明確規定:第一個變更受保護狀態的終止事件勝出。

介面卡必須保證什麼?

已檢查的延續會將同步回呼橋接至非同步 Swift 程式碼。Apple 要求每條執行路徑都恰好恢復它一次。若未恢復,工作就會持續暫停;執行階段檢查有助於找出誤用,但不會替你實作協調機制。CheckedContinuation

取消會成為另一個可能負責恢復延續的事件。Swift 的取消採合作式設計:取消工作不會自動停止任意的回呼操作,也不會讓延續自動擲出錯誤。介面卡必須明確將取消轉換成所選的結果。Task.cancel()

在這個範例中,值的契約如下:

  • -1000 到 1000 之間的輸入會產生其兩倍的值。
  • 其他輸入會產生 CalculationError.outOfRange。
  • 若取消在狀態轉換中勝出,則會產生 CancellationError。
  • 選定終止結果後,後續的完成或取消事件都不能取代它。
  • 每次呼叫都會擁有一個全新的閘門,並安裝一個延續。

這表示即使在呼叫端收到值之前發生取消,完成仍可能勝出。決定勝負的時刻是受保護的狀態轉換,而不是呼叫端下一次執行的時間。這是本範例所選用的應用程式策略,不是 Swift 提供的通用排序保證。

為什麼取消可能在安裝前發生?

Apple 文件指出,若工作已取消,取消處理常式會先於操作閉包執行。操作仍然會執行。取消也可能與已在進行中的操作並行發生。因此,若處理常式只恢復一個尚未填入的可選延續,就會遺失取消事件。withTaskCancellationHandler(operation:onCancel:isolation:)

因此,閘門需要記錄的不只是目前是否有延續。延續不存在,可能表示尚未安裝,也可能表示已選定終止結果。這兩種情況需要不同的處理方式。

受保護的狀態 安裝延續 收到完成或取消
空 儲存延續 儲存終止結果
等待中 拒絕第二次安裝 移除延續並儲存終止結果
已結束 以已儲存的結果恢復新安裝的延續 忽略後續的終止事件

這張表也能協助診斷問題。如果實作無法在安裝前表示終止結果,請檢查工作已取消時的路徑。如果兩個終止事件都能取出延續,請檢查狀態確認與移除是否在同一個臨界區段內完成。

在 Mac 上建立本機範例

請使用包含 macOS SDK 的 Swift 6 工具鏈。在 Terminal 中執行 swift --version,確認所選工具鏈。下方程式僅使用 Foundation 和 Dispatch,不需要套件相依性。Swift 的命令列指南說明如何檢查安裝,以及使用非同步 @main 進入點。Build a Command-line Tool

依序將下方三個 Swift 程式碼區塊存入同一個名為 ContinuationRace.swift 的檔案。三個區塊合起來就是一個完整程式。第一個區塊實作閘門,第二個提供計算與介面卡,第三個則提供可執行的檢查。研究環境中無法使用 Swift,因此本文只檢視了程式,並未在此編譯或執行。

實作同步閘門

Foundation 文件指出,NSLock 可用來保護臨界區段。鎖定與解鎖必須在同一個執行緒上進行,而且此鎖不可遞迴使用。在此,每個臨界區段都會留在同步方法內,不包含暫停點,並且會在恢復延續之前結束。NSLock

import Foundation
import Dispatch

final class IntContinuationGate: @unchecked Sendable {
    private enum State {
        case empty
        case waiting(CheckedContinuation<Int, any Error>)
        case finished(Result<Int, any Error>)
    }

    private let lock = NSLock()
    private var state: State = .empty
    private var didInstall = false

    // Returns false if a terminal event already won.
    func install(
        _ continuation: CheckedContinuation<Int, any Error>
    ) -> Bool {
        lock.lock()
        precondition(!didInstall, "Install exactly once per gate")
        didInstall = true

        switch state {
        case .empty:
            state = .waiting(continuation)
            lock.unlock()
            return true

        case .waiting:
            preconditionFailure("Unexpected second installation")

        case .finished(let result):
            lock.unlock()
            continuation.resume(with: result)
            return false
        }
    }

    func finish(_ result: Result<Int, any Error>) {
        let continuation: CheckedContinuation<Int, any Error>?

        lock.lock()
        switch state {
        case .empty:
            continuation = nil
            state = .finished(result)

        case .waiting(let waiting):
            continuation = waiting
            state = .finished(result)

        case .finished:
            lock.unlock()
            return
        }
        lock.unlock()

        continuation?.resume(with: result)
    }

    func cancel() {
        finish(.failure(CancellationError()))
    }
}

閘門使用 @unchecked Sendable,因為其可變儲存空間是由程式手動保護。這個註記會將責任交給實作者,不會自行加入同步機制。兩個可變屬性都維持為私有,且每次存取都會使用同一把鎖。Apple 明確指出,以鎖保護狀態是滿足 unchecked sendability 語意需求的一種方式。Sendable

請注意,finish 會在解鎖前變更狀態。如此一來,競爭中的終止事件只能看到 finished,即使勝出的執行緒尚未呼叫 resume 也一樣。勝出者會在本機保留取出的延續;其他呼叫無法再次取出它。

將完成與取消連接至同一個閘門

回呼計算會使用全域派送佇列。Apple 文件指出,非同步提交的工作會獨立於提交程式碼繼續執行,而且背景派送工作沒有保證的執行緒識別。因此,此處適合使用不依賴特定執行緒的協調方式。DispatchQueue

enum CalculationError: Error {
    case outOfRange
}

func startDoubling(
    _ value: Int,
    completion: @escaping @Sendable (Result<Int, any Error>) -> Void
) {
    DispatchQueue.global().async {
        guard (-1000...1000).contains(value) else {
            completion(.failure(CalculationError.outOfRange))
            return
        }
        completion(.success(value * 2))
    }
}

func doubled(_ value: Int) async throws -> Int {
    let gate = IntContinuationGate()

    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            guard gate.install(continuation) else { return }

            startDoubling(value) { result in
                gate.finish(result)
            }
        }
    } onCancel: {
        gate.cancel()
    }
}

請在進入取消處理常式範圍前建立閘門。如此一來,已取消的工作就能立即記錄取消。之後安裝延續時,會以已儲存的錯誤恢復它並回傳 false,因此介面卡會略過啟動計算。

安裝回傳 true 後、呼叫 startDoubling 前,取消仍可能發生。在這種順序下,計算會啟動並完成,但因為取消已勝出,其結果會被忽略。包裝層會取消等待,但不承諾停止這個小型且有界限的計算。

受支援的延續橋接方式是 withCheckedThrowingContinuation;其閉包會收到用來回傳值或擲出錯誤的延續。範例明確指定延續的整數結果型別與錯誤型別,避免型別推斷產生歧義。withCheckedThrowingContinuation(isolation:function:_:)

不靠休眠檢查事件順序

延遲一段時間無法確定競速中是哪個事件勝出。接下來的檢查會直接透過閘門執行安裝、完成與取消的全部六種順序。每個序列也會再收到額外的終止事件,以確認已選定的結果維持不變。

enum GateEvent {
    case install
    case complete
    case cancel
}

func checkOrder(
    _ events: [GateEvent],
    expected: Int?
) async throws {
    let gate = IntContinuationGate()

    do {
        let value: Int = try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            for event in events {
                switch event {
                case .install:
                    _ = gate.install(continuation)
                case .complete:
                    gate.finish(.success(42))
                case .cancel:
                    gate.cancel()
                }
            }

            // Neither of these may replace the selected result.
            gate.finish(.success(99))
            gate.cancel()
        }

        guard let expected else {
            preconditionFailure("Expected cancellation")
        }
        precondition(value == expected, "Unexpected value")
    } catch is CancellationError {
        precondition(expected == nil, "Unexpected cancellation")
    }
}

@main
struct ContinuationRaceDemo {
    static func main() async throws {
        try await checkOrder(
            [.install, .complete, .cancel], expected: 42
        )
        try await checkOrder(
            [.install, .cancel, .complete], expected: nil
        )
        try await checkOrder(
            [.complete, .install, .cancel], expected: 42
        )
        try await checkOrder(
            [.complete, .cancel, .install], expected: 42
        )
        try await checkOrder(
            [.cancel, .install, .complete], expected: nil
        )
        try await checkOrder(
            [.cancel, .complete, .install], expected: nil
        )

        let value = try await doubled(21)
        precondition(value == 42)

        do {
            _ = try await doubled(1001)
            preconditionFailure("Expected outOfRange")
        } catch CalculationError.outOfRange {
            // Expected input-domain error.
        }

        // Integration smoke check: either terminal outcome is valid.
        for _ in 0..<100 {
            let task = Task { try await doubled(21) }
            task.cancel()
            do {
                let result = try await task.value
                precondition(result == 42)
            } catch is CancellationError {
                // Cancellation won.
            }
        }

        print("Event-order checks and integration checks passed.")
    }
}

請在檔案所在目錄編譯並執行:

swiftc -swift-version 6 -parse-as-library ContinuationRace.swift -o ContinuationRace
./ContinuationRace

預期的成功輸出為 Event-order checks and integration checks passed.。明確的順序檢查會驗證狀態轉換。整合迴圈會執行實際的介面卡,但無法保證某種特定的排程交錯會發生,也無法證明已測試所有並行執行情況。

為什麼此實作能確保恰好恢復一次?

若要證明最多只恢復一次,請看第一次成功呼叫 finish 的情況。它會以原子方式將目前狀態取代為 finished。若有延續正在等待,只有這次呼叫能取出它。之後每次呼叫 finish 都會直接返回,不會恢復任何內容。若當時尚無延續,結果就會等到唯一一次安裝發生。

若要證明最終會恢復,則有兩種路徑。第一種是先安裝,第一個終止事件會恢復等待中的延續;第二種是先發生終止事件,安裝時會以已儲存的結果恢復延續。此推論假設只安裝一次,而且至少會發生一個終止事件。若一般回呼來源永遠不會完成,且工作也永遠不會取消,呼叫端仍會一直等待。

此範例的回呼會執行有界限的計算,並在成功與輸入錯誤的路徑上呼叫完成。勝出者確定後,重複的終止回呼會刻意忽略。這適用於指定的單一結果契約;若要處理有意義的值串流,則需要不同的介面。

哪些捷徑會破壞契約?

在未同步的情況下先檢查可選延續,再將它設為 nil,是不足夠的。兩個並行呼叫者可能都在任一方移除延續之前,讀取到同一個非 nil 延續。狀態檢查、取出延續,以及變更終止狀態,必須是同一個受保護的操作。

在建立延續前先檢查取消狀態也會留下空隙:檢查完成後,取消可能立刻發生。處理常式與記錄下來的終止狀態會填補這個空隙。Swift 語言指南將取消描述為合作式機制,並指出取消處理常式是即時通知的方式。Concurrency — The Swift Programming Language

持有共用鎖時恢復延續會引發另一個問題。Apple 特別警告,取消處理常式可能在內部工作鎖定期間執行,而恢復延續可能會取得那些鎖。因此,範例會在每次呼叫 resume 前先解鎖。取消處理常式與鎖

最後,在延續回傳後加入 Task.checkCancellation() 會改變此範例對外的契約:即使完成已在閘門中勝出,取消仍可能取代其值。請審慎決定此策略。依照此處實作的契約,安裝、完成與取消都使用同一個狀態機,第一個終止狀態轉換會決定呼叫端收到的結果。

來源