若要讓 Swift 計算可取消,請保留其工作控制代碼,在收到要求時呼叫 cancel(),並在迴圈內檢查是否已取消。這個 macOS 範例也會讓介面保持回應,並避免已取消的執行稍後顯示結果。
取消 Swift 工作實際上會做什麼?
Swift 使用合作式取消:提出取消要求會標記工作,而執行中的程式碼決定何時停止。從不檢查取消狀態的 CPU 迴圈可能會持續執行到完成。觀察到取消時,呼叫 Task.checkCancellation() 會拋出 CancellationError。Apple 的取消說明文件、checkCancellation()。
此處的讀者任務很明確:在一個計算合成總和的小型 Mac app 中,加入可正常運作的 Start 和 Cancel 按鈕。所有輸入都在本機產生。計算會將連續正整數的平方根相加,只有在處理完所有要求的項目後才產生一個 Double。
實作取消前,先選定結果契約。此範例會捨棄尚未完成的總和,並另外回報取消。若要傳回部分總和,就需要另一種契約,指出其中包含多少項目;否則,呼叫端可能會把未完成的工作誤認為要求的答案。Swift 語言指南列出拋出錯誤、傳回空結果,以及傳回部分工作成果,作為可能的取消回應。Concurrency。
需要實作兩項責任:停止不必要的運算,並判斷已完成的值是否仍應顯示在介面上。此範例兩者都會處理,包括在工作執行最後一次檢查之後、結果顯示之前才收到取消要求的情況。
在計算中加入取消檢查
在 Xcode 中使用 SwiftUI 和 Swift 建立 macOS app 專案。此範例使用 Swift 6 語言模式,部署目標為 macOS 13 或更新版本。加入以下完整檔案,命名為 SyntheticCalculation.swift。此檔案不依賴檢視。
// SyntheticCalculation.swift
import Swift
enum SyntheticCalculation {
nonisolated static func sumOfSquareRoots(
terms: Int,
checkEvery: Int = 4_096
) throws -> Double {
precondition(terms >= 0, "terms must be nonnegative")
precondition(checkEvery > 0, "checkEvery must be positive")
try Task.checkCancellation()
var completed = 0
var total = 0.0
while completed < terms {
try Task.checkCancellation()
let count = min(checkEvery, terms - completed)
let end = completed + count
while completed < end {
completed += 1
total += Double(completed).squareRoot()
}
}
try Task.checkCancellation()
return total
}
}
此函式會在開始前、每個區塊邊界,以及傳回前檢查取消狀態。這些是刻意設置的停止點:若在處理某個區塊期間收到取消要求,會在下一次檢查時觀察到。Task.checkCancellation() 是同步的,因此計算可以維持為一般會拋出錯誤的函式;僅為了檢查取消狀態,並不需要使用 await。checkCancellation()。
區塊計算也能維持清楚的輸入邊界。若工作未取消且項目數為零,結果就是零。負數項目數和非正數區塊大小屬於程式設計錯誤,會由前置條件加以強制檢查。從剩餘數量計算每個區塊,可避免將終點推進到超過 terms。
累加器會留在工作執行緒的區域範圍內。取消錯誤一旦拋出,函式就不會傳回該累加器,這實現了所選的完整結果契約。由於這是浮點數加法,完成後的值只是數學總和的近似值。
讓「Cancel」按鈕保持回應
若按鈕動作沒有機會執行,取消檢查就無法發揮作用。Apple 說明,在 SwiftUI 檢視中建立一般 Task,並在其中執行繁重的同步工作,仍可能占用主動作器,因為該工作會繼承周圍的動作器內容。Apple 將分離工作記錄為在該繼承內容之外執行同步工作的支援方式。改善 app 回應能力。
此處的分離工作者只捕捉不可變的整數,並呼叫明確標記為 nonisolated 的同步函式。所有介面狀態都留在主動作器上。另一個主動作器工作會等待工作者的值,並更新顯示內容。
將產生的 app 進入點檔案替換為以下完整檔案;若仍有產生的 ContentView.swift,也請移除。目標中只能保留一個 @main 宣告,這是 SwiftUI app 生命週期的要求。這兩個檔案合起來就是範例 app。App。
// CancellationDemoApp.swift
import Foundation
import SwiftUI
@main
struct CancellationDemoApp: App {
var body: some Scene {
WindowGroup {
CalculationView()
}
}
}
@MainActor
struct CalculationView: View {
@State private var worker: Task<Double, Error>?
@State private var runID: UUID?
@State private var status = "Ready"
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Synthetic square-root sum")
.font(.headline)
Text(status)
.textSelection(.enabled)
HStack {
Button("Start") {
start()
}
.disabled(worker != nil)
Button("Cancel") {
guard let worker else { return }
worker.cancel()
status = "Cancellation requested"
}
.disabled(worker == nil)
}
}
.padding(24)
.frame(minWidth: 420, minHeight: 160)
.onDisappear {
worker?.cancel()
runID = nil
worker = nil
status = "Ready"
}
}
private func start() {
guard worker == nil else { return }
let id = UUID()
let terms = 500_000_000
runID = id
status = "Calculating"
let run = Task.detached(priority: .userInitiated) {
try SyntheticCalculation.sumOfSquareRoots(terms: terms)
}
worker = run
Task { @MainActor in
do {
let value = try await run.value
guard runID == id else { return }
if run.isCancelled {
status = "Cancelled"
} else {
status = "Result: \(value)"
}
} catch is CancellationError {
guard runID == id else { return }
status = "Cancelled"
} catch {
guard runID == id else { return }
status = "Failed: \(error)"
}
guard runID == id else { return }
worker = nil
runID = nil
}
}
}
保留 worker 至關重要。丟棄工作控制代碼並不會取消工作,只會讓你無法透過該控制代碼取消工作。等待會拋出錯誤的工作者之 value 也會觀察其錯誤。分離工作需要明確管理生命週期,因此按鈕和消失處理常式會直接取消工作者。只取消等待其結果的工作,並不代表取消會傳遞至這個分離工作者。分離工作說明文件。
Foundation 的 UUID 會為每次執行提供識別碼。識別碼比對是應用程式層級的規則:檢視消失後,只要某次執行失效,即使它稍後完成,也不能更新此檢視目前的狀態。識別碼不會停止運算;worker.cancel() 和迴圈中的檢查才負責這件事。UUID。
在工作者完成或拋出錯誤前,「Start」按鈕會維持停用狀態。按下「Cancel」後會先顯示 Cancellation requested;結果處理工作在處理完結果後,會將其改為 Cancelled。這樣可以區分取消要求與確認工作已結束。
如何處理延遲到達的結果?
試想以下順序:工作者通過最後一次檢查並完成,接著使用者在主動作器接收其值之前按下「Cancel」。Apple 明確指出,在最後一個可能的檢查之後才取消,可能不會對工作者造成可觀察的影響。最後再檢查一次可以縮短這段時間,但無法完全消除。cancel()。
因此,此範例會在成功等待到值之後檢查 run.isCancelled。這裡檢查的是工作者的控制代碼。若在此使用靜態的 Task.isCancelled,檢查到的會是結果觀察工作本身。Swift 語言指南區分了透過型別屬性檢查目前工作,以及透過執行個體屬性檢查另一個工作。Concurrency。
這會建立一項具體的接受規則:若在接受結果之前處理了取消要求,就以取消為準。若接受已完成且工作者已清除,停用的「Cancel」按鈕就沒有已完成的結果可撤回。這項規則依主動作器動作的順序執行,不代表取消可以逆轉已完成的工作。
選擇實用的取消檢查間隔
預設區塊大小為 4,096 項,是示範用的起始值。較小的區塊能更頻繁地提供觀察取消狀態的機會,但也會執行更多檢查。較大的區塊會降低檢查頻率,但兩個停止點之間可以進行更多運算。
粗略估算時,假設一個區塊包含 4,096 項,而每一項需花費示範用的兩微秒。該區塊的計算時間約為 8.2 毫秒。這些是假設的輸入值,用來示範計算方式,並非 Swift 或特定 Mac 的測量結果。排程和介面更新也可能增加延遲。
| 觀察到的情況 | 檢查或變更的項目 |
|---|---|
| 計算期間介面停止回應 | 確認迴圈在分離工作者中執行,且不受主動作器隔離。 |
| 介面有回應,但取消耗時過久 | 調小 checkEvery,並測量最長區塊的執行時間。 |
| 區塊後來加入一項昂貴操作 | 若其實作支援,請在該操作內加入停止點。 |
| 已取消的執行仍顯示結果 | 在接受結果時檢查工作者控制代碼,並保留執行識別碼的防護條件。 |
不要只為了替這個迴圈設定另一個旗標而加入取消處理常式。工作本身已有取消狀態可供檢查,而 Swift 語言指南也說明,安裝處理常式後,停止方式仍是合作式的。對這項本機計算而言,明確檢查就足以提供所需的停止機制。Concurrency。
驗證完成、取消和重複執行
研究環境中沒有 Swift 編譯器和 macOS 執行環境,因此此範例僅經過檢視,尚未編譯或執行。請在 Xcode 中建置這兩個檔案,然後驗證以下不同行為:
- 小型完成結果: 暫時將
terms改為4。顯示結果應約為6.146264369941973,並容許浮點數捨入誤差。 - 空輸入: 將
terms改為0。未取消的執行應顯示零。 - 計算期間取消: 還原為
500_000_000,按下Start,接著在執行期間按下Cancel。預期先看到Cancellation requested,然後看到Cancelled,且不會出現部分結果。 - 再次執行: 取消完成後,再按一次
Start。新的執行應能正常開始。 - 檢視消失: 關閉正在計算的視窗。消失處理常式應提出取消要求,並使該次執行的介面更新失效。
大項目數的目的,是留出互動時間,但所需時間會依 Mac 和建置設定而異。若工作在你取消之前就完成,請在支援的整數範圍內提高項目數。請用較小的輸入驗證完成後的算術結果,不必等待大型示範計算完成。
完成後的設計具備清楚的停止路徑和結果接受規則:按鈕會取消已保留的工作者,區塊邊界讓計算可以拋出錯誤,而主動作器只接受目前執行中尚未取消的結果。這三項決定讓取消功能適用於一般的 Mac 計算。
來源
- Concurrency — Swift 專案的《Swift 程式語言》。
- checkCancellation() — Apple Developer 說明文件。
- cancel() — Apple Developer 說明文件。
- 改善 app 回應能力 — Apple Developer 說明文件。
- detached(name:priority:operation:) — Apple Developer 說明文件。
- App — Apple Developer 說明文件。
- UUID — Apple Developer 說明文件。