在第一次暫停之前,先將計算的 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、等待該控制代碼,並且只清除已完成的那輪工作。
來源
- 並行 — Swift 程式語言 — Swift.org。
- Task — Apple 開發者文件。
- value — Apple 開發者文件。
- detached(name:priority:operation:) — Apple 開發者文件。
- sleep(for:tolerance:clock:) — Apple 開發者文件。
- UUID — Apple 開發者文件。
- 使用 Swift actors 保護可變狀態 — Apple 開發者。