若要確保 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() 會改變此範例對外的契約:即使完成已在閘門中勝出,取消仍可能取代其值。請審慎決定此策略。依照此處實作的契約,安裝、完成與取消都使用同一個狀態機,第一個終止狀態轉換會決定呼叫端收到的結果。
來源
- CheckedContinuation — Apple。
- withTaskCancellationHandler(operation:onCancel:isolation:) — Apple。
- cancel() — Apple。
- NSLock — Apple。
- Sendable — Apple。
- DispatchQueue — Apple。
- withCheckedThrowingContinuation(isolation:function:_:) — Apple。
- Build a Command-line Tool — Swift.org。
- Concurrency — The Swift Programming Language — The Swift Project。