在第一次暫停之前,先將計算的 task 存放在 actor 中,接著讓並行呼叫者等待同一個 task。本指南為 Mac 工具建立一個本機平方和範例,並明確訂定取消、錯誤與清理的規則。

並行呼叫者應共用什麼?

假設 Mac 桌面工具的兩個部分需要相同的計算摘要:預覽和詳細資訊面板。兩者都要計算合成陣列 [1, 2, 3, 4] 中各數值的平方和。兩者都應收到 30,而加入同一個執行中工作階段的請求應共用一次執行。

以下實作讓每個計算器執行個體持有不可變的輸入陣列。只要目前的 task 仍占用 actor 的執行中工作槽,就共用該 task;當呼叫者觀察到工作完成時,再清除該工作槽。之後的請求可以啟動另一輪計算。這是共用進行中的工作;若要無限期保留已完成的答案,則需要另外制定快取政策。

這個值的契約刻意保持精簡:

  • 最多接受 100,000 個整數,每個整數都介於 0 和 1,000 之間。
  • 回傳這些整數的平方和;空陣列回傳 0。
  • 附上執行識別碼,讓呼叫者能區分共用的執行與只是總和相同的情況。
  • 將計算錯誤傳遞給等待該輪工作的呼叫者。
  • 讓個別呼叫者取消其等待,而不取消共用的 worker。

允許的最大總和是 100,000 × 1,000² = 100,000,000,000,可容納於 Mac 的 64 位元 Int。這些限制屬於本範例的契約。若要更改,必須重新考量算術溢位與資源使用。

為什麼 actor 還需要儲存 task?

actor 會保護其可變狀態,但非同步 actor 方法可能暫停。暫停期間,另一個呼叫可以進入 actor。Swift 語言指南說明了 actor 隔離和暫停點,而 Apple 的 actor 說明會展示,在 await 之前作出的假設如何變得過時。並行 — Swift 程式語言、使用 Swift actors 保護可變狀態。

因此,檢查是否已有答案,然後等待新的計算,並不能防止重複工作。答案尚未產生之前,另一個呼叫者可能就已到達。相反地,應同步發布 task 控制代碼:檢查執行中工作槽,只有在槽位空著時才建立 worker,將它存入槽位,然後等待其結果。

Apple 的文件指出,task 建立後可能立即開始。存取其非同步 value 會等待工作完成,並傳遞拋出的錯誤。因此,actor 負責協調單一控制代碼的所有權;呼叫者透過該控制代碼取得結果。Task、value。

建立完整的本機範例

請使用 macOS 13 或更新版本上的 Swift 6 編譯器。此處要求 macOS,是因為示範使用了 Task.sleep(for:) 呼叫。該呼叫會暫停而不阻塞底層執行緒;這段短暫等待讓重疊請求更容易觀察。它不會執行任何算術運算,之後可以移除。sleep(for:tolerance:clock:)。

將以下完整程式儲存為 SharedCalculation.swift。它使用一般的非隔離預設設定,供下方的命令列編譯使用。Foundation 提供 UUID,其文件列出的遵循協定包含 Sendable 和 Equatable;這個識別碼會標記每次執行,並支援有條件的清理。UUID。

import Foundation

struct CalculationResult: Sendable {
    let total: Int
    let runID: UUID
}

enum CalculationError: Error {
    case invalidInput
}

actor SharedCalculation {
    private struct Flight {
        let id: UUID
        let task: Task<CalculationResult, Error>
    }

    private let samples: [Int]
    private var flight: Flight?

    init(samples: [Int]) {
        self.samples = samples
    }

    func value() async throws -> CalculationResult {
        try Task.checkCancellation()

        let active: Flight
        if let existing = flight {
            active = existing
        } else {
            let id = UUID()
            let snapshot = samples
            let task = Task.detached(priority: .userInitiated) {
                [snapshot, id] () async throws -> CalculationResult in
                guard snapshot.count <= 100_000,
                      snapshot.allSatisfy({ (0...1_000).contains($0) })
                else {
                    throw CalculationError.invalidInput
                }

                // Demonstration only: allow overlap to be observed.
                try await Task.sleep(for: .milliseconds(100))
                let total = snapshot.reduce(0) { sum, sample in
                    sum + sample * sample
                }
                return CalculationResult(total: total, runID: id)
            }
            active = Flight(id: id, task: task)
            flight = active
        }

        defer {
            // An older waiter must not clear a newer flight.
            if flight?.id == active.id {
                flight = nil
            }
        }

        let result = try await active.task.value
        try Task.checkCancellation()
        return result
    }
}

@main
struct Demo {
    static func main() async throws {
        let calculation = SharedCalculation(samples: [1, 2, 3, 4])

        async let preview = calculation.value()
        async let details = calculation.value()
        async let inspector = calculation.value()

        let (a, b, c) = try await (preview, details, inspector)
        precondition(a.total == 30 && b.total == 30 && c.total == 30)

        let observedRuns = Set([a.runID, b.runID, c.runID]).count
        print("Total: \(a.total)")
        print("Runs observed across three requests: \(observedRuns)")

        let later = try await calculation.value()
        precondition(later.total == 30)
        print("Later request started another run: \(later.runID != c.runID)")
    }
}

在包含該檔案的目錄中編譯並執行:

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

算術斷言應會通過。當三個初始呼叫都在執行中工作遭清除之前進入時,觀察到的執行次數為 1。後續請求是在那些呼叫返回後才到達,因此會啟動另一輪計算。這段暫停有助於產生重疊,但不保證排程結果:若某個初始請求延遲太久,之後啟動另一輪工作也是合理的。研究環境中無法使用 Swift,因此這個程式在此僅經過檢視,並未編譯或執行。

worker 使用 detached task,因為這項計算屬於共用計算器,而非某一位請求者;其算術運算也應獨立於負責協調的 actor 執行。它只擷取輸入快照和識別碼。Apple 建議在能表達所需生命週期時使用結構化子 task;detached task 則需要明確決定生命週期和取消方式。本範例也明確指定了優先順序。detached(name:priority:operation:)。

清理時如何避免清除下一輪計算?

檢查並儲存的順序中沒有 await,所以第二個 actor 呼叫無法在檢查空槽與發布新工作之間插入。第一個呼叫者在 active.task.value 上暫停後,另一個呼叫者便能進入、找到該工作,並等待相同的控制代碼。

清理需要自行檢查識別碼,因為多個呼叫者可能在同一個已完成的 task 上、於不同時間恢復執行。請看以下情況:

事件 執行中工作槽
呼叫者 A 和 B 加入執行 X X
X 完成;A 恢復並清除 X 空
呼叫者 C 進入並啟動執行 Y Y
B 從 X 恢復並執行清理 Y 必須保留

若 B 無條件將槽位設為 nil,就會清除 Y 的控制代碼。此時若第四位呼叫者到達,Y 仍在執行,它卻可能啟動重複工作。比對 flight?.id 和 active.id,可確保清理只作用於各呼叫者實際加入的那輪工作。這個情況是實作方式與文件所述重新進入行為的結果。使用 Swift actors 保護可變狀態。

共用的確切界線是已儲存的執行中工作槽。worker 可能已完成,但等待中的呼叫者尚未恢復執行以清除槽位。在這段期間進入的請求仍可能取得已完成 task 的結果。槽位清除後,新請求就會啟動新一輪工作。這段短暫的完成間隔屬於契約的一部分。

呼叫者取消或計算失敗時會怎樣?

Swift 的取消機制採合作式:程式碼必須決定在哪裡檢查取消,以及要如何處理。本範例會在加入工作之前檢查請求者的 task,並在成功取得共用結果後再次檢查。Task — Task 取消。

若呼叫者在第一次檢查時已取消,就會拋出錯誤,不會啟動或加入工作。若呼叫者在等待期間取消,仍會繼續等待共用 worker;worker 成功回傳結果後,第二次檢查會拋出 CancellationError。其他呼叫者仍可取得結果,因為此實作絕不會將個別呼叫者的取消傳遞給 worker。

此政策不會立即取消等待。即使所有呼叫者都取消,worker 仍會完成。對這項有界的本機計算而言,這是刻意的選擇。若設計需要個別呼叫者能立即離開,就必須額外追蹤等待者,並明確定義何時停止共用 worker。

無效輸入會使 worker 拋出 CalculationError.invalidInput。已共用該 task 的呼叫者會透過 value 收到錯誤,並以延遲清理釋放相符的工作槽。之後的請求會使用相同的不可變輸入重試,並再次失敗。程式不會悄悄代入總和,也不會丟棄無效元素。value。

還有一個明確的錯誤排序細節:若呼叫者在 worker 失敗時取消,會先傳遞 worker 的錯誤,再進行第二次取消檢查。若 worker 成功,則由呼叫者的取消檢查決定該呼叫者是否回傳結果。

如何在 Mac 工具中驗證並使用此模式?

總和相同本身無法證明執行有共用:對這份輸入分別進行三次計算,也會得到 30。檢查重疊情況時,請比對執行識別碼,並另外驗證數值結果。

若要進行確定性的並行測試,請將示範中的暫停替換為可控制的非同步閘門,並記錄呼叫者加入執行中工作的時機。讓 worker 保持等待,直到所有預期的呼叫者都已加入,再將它釋放。檢查 worker 只啟動一次、識別碼相符、總和正確、錯誤有傳遞,以及清理後會啟動新一輪工作。也要測試表格中的清理情況。單純延長睡眠時間無法可靠地確保這些條件。

在桌面應用程式中,對使用相同輸入的消費端維持同一個計算器執行個體。若為每個面板建立新執行個體,每個面板就會有獨立的執行中工作槽。合成輸入改變時,請為該快照建立新的計算器;本範例並未定義可變輸入或依鍵值共用工作。

在主 actor 上將回傳值套用至 UI 狀態。Swift 語言指南記載了用於 UI 工作的 @MainActor;共用計算器則可繼續各自協調其狀態。並行 — Swift 程式語言。

對於小型總和計算,建立 task 是不必要的額外開銷;本範例讓所有權規則清晰可見,以便套用到更大型的本機計算。改編時請保留關鍵順序:在暫停前發布一個 task、等待該控制代碼,並且只清除已完成的那輪工作。

來源