若要讓 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 中建置這兩個檔案,然後驗證以下不同行為:

  1. 小型完成結果: 暫時將 terms 改為 4。顯示結果應約為 6.146264369941973,並容許浮點數捨入誤差。
  2. 空輸入: 將 terms 改為 0。未取消的執行應顯示零。
  3. 計算期間取消: 還原為 500_000_000,按下 Start,接著在執行期間按下 Cancel。預期先看到 Cancellation requested,然後看到 Cancelled,且不會出現部分結果。
  4. 再次執行: 取消完成後,再按一次 Start。新的執行應能正常開始。
  5. 檢視消失: 關閉正在計算的視窗。消失處理常式應提出取消要求,並使該次執行的介面更新失效。

大項目數的目的,是留出互動時間,但所需時間會依 Mac 和建置設定而異。若工作在你取消之前就完成,請在支援的整數範圍內提高項目數。請用較小的輸入驗證完成後的算術結果,不必等待大型示範計算完成。

完成後的設計具備清楚的停止路徑和結果接受規則:按鈕會取消已保留的工作者,區塊邊界讓計算可以拋出錯誤,而主動作器只接受目前執行中尚未取消的結果。這三項決定讓取消功能適用於一般的 Mac 計算。

來源