雲端 Mac 上的 iOS 有界並行任務排程驗收
批次處理二十份附件時,把所有任務加入任務群組,再逐一讀取結果,並不代表已限制並行數。網路連線、解碼緩衝區與暫存檔仍可能同時增加。在雲端 Mac 上驗收這段邏輯,重點不是執行速度,而是證明:上限為三時,第四個操作必須等到有空位才能開始。
以下先將排程器與網路實作分離,用可控制的操作驗證槽位,再接回 iOS 業務程式碼。這樣一來,測試失敗時就能分辨是排程錯誤,還是外部服務不穩定,不必反覆重跑才能得出結論。
先定義可檢查的排程契約
約定輸出順序與輸入順序一致,但完成順序可以不同;空輸入回傳空陣列;並行上限必須大於零。任何子任務擲出錯誤後,整個呼叫都應失敗,不交付部分結果。父任務取消後,不應繼續主動補入尚未執行的工作。
這裡限制的是傳入操作中正在執行的數量,不是執行緒數,也不是請求速率。若一個操作在內部另外發出多個請求,這些請求不在外層的計數範圍內。每秒請求配額必須另外實作,不能以並行槽位代替。
取消是協作式訊號,不是強制終止。任務群組離開作用域前,仍會等待子任務結束;忽略取消的操作會拖住整個呼叫。
收到完成事件後再補入下一個任務
將以下程式碼儲存為 BoundedMap.swift。在已安裝 Swift 工具鏈的雲端 Mac 上執行,先用 swift --version 記錄版本。範例採用嚴格並行檢查所需的 Sendable 約束,不依賴圖形工作階段或模擬器。
import Foundation
func boundedMap<Input: Sendable, Output: Sendable>(
_ inputs: [Input],
limit: Int,
operation: @escaping @Sendable (Input) async throws -> Output
) async throws -> [Output] {
precondition(limit > 0)
return try await withThrowingTaskGroup(
of: (Int, Output).self
) { group in
var nextIndex = 0
var completed: [(Int, Output)] = []
completed.reserveCapacity(inputs.count)
for index in 0..<min(limit, inputs.count) {
try Task.checkCancellation()
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
while let result = try await group.next() {
completed.append(result)
try Task.checkCancellation()
if nextIndex < inputs.count {
let index = nextIndex
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
}
try Task.checkCancellation()
return completed.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
一開始只提交上限數量的任務。之後每收到一個完成結果,才提交一個新任務,因此不會因為輸入很多,就建立同樣數量的等待任務。索引隨結果一起回傳,最後再還原輸入順序,避免把「先完成」誤當成「排在前面」。
錯誤透過 group.next() 傳遞;閉包擲出錯誤並結束時,任務群組會取消剩餘子任務,並等待它們完成收尾。這裡不會捕捉錯誤後繼續補入任務,也沒有使用脫離父任務生命週期的獨立背景任務。
用 actor 探針測量實際執行中的操作數
將以下程式碼接在同一個檔案末尾,再執行 swift BoundedMap.swift。探針在操作進入時加一,退出時減一;睡眠期間,actor 仍可接收其他呼叫,因此不會意外地讓所有工作變成循序執行。
actor Probe {
private var active = 0
private var peak = 0
func run(_ value: Int) async throws -> Int {
active += 1
peak = max(peak, active)
defer { active -= 1 }
let delay = UInt64(5 - value % 5) * 10_000_000
try await Task.sleep(nanoseconds: delay)
return value * 2
}
func snapshot() -> (active: Int, peak: Int) {
(active, peak)
}
}
let probe = Probe()
let inputs = Array(0..<20)
let output = try await boundedMap(inputs, limit: 3) {
try await probe.run($0)
}
let state = await probe.snapshot()
precondition(output == inputs.map { $0 * 2 })
precondition(state.active == 0)
precondition(state.peak <= 3)
設定不同延遲是為了打亂完成順序,不是效能基準測試。不要斷言某個任務必須在特定毫秒數內結束,也不要因為機器較忙、峰值未達三,就判定為錯誤。必須成立的條件是:峰值不超過上限、結果完整,以及呼叫結束後執行中的操作數歸零。
也應分別以空陣列、單一元素及上限一執行測試。若呼叫端從設定檔讀取上限,必須先拒絕零與負數;範例中的前置條件適合用來揭露程式錯誤,不適合直接處理使用者輸入。
失敗與取消必須分別驗收
成功案例無法證明取消行為正確。先建立一個遇到指定輸入就必定擲出錯誤的操作,斷言呼叫失敗,且探針計數最終歸零。不要斷言失敗前恰好啟動了多少個任務:在錯誤傳到任務群組之前,其他任務可能已經完成,並觸發補位。
取消案例應使用可控制的閘門:讓首批操作回報「已進入」,測試收到這些訊號後取消父任務,再開啟閘門。操作通過閘門後檢查取消狀態,測試則等待父任務結束,最後確認所有已進入的操作都已退出。
不要用固定睡眠時間猜測任務是否已啟動。閘門本身也必須能釋放所有等待者,否則測試會把測試夾具的死結誤判成排程器卡住。驗收報告應區分取消請求時間、操作退出時間與父任務返回時間。
接回業務邏輯時守住槽位邊界
網路下載應將下載、必要的解碼與暫存檔收尾,放在同一個操作作用域內。若收到回應就釋放槽位,卻將大型圖片解碼交給背景任務,網路並行數雖然受控,記憶體峰值仍會失控。
這份實作會保留全部輸出,所需空間會隨結果總量增加。處理大型檔案時,宜回傳檔案位置與小型摘要,而不是把檔案內容全部放進陣列。若需要在結果完成時逐一消費的管線,應另外定義輸出背壓契約,不能直接宣稱這份實作具有固定的記憶體用量。
最後應留存工具鏈版本、測試輸入、上限、峰值與退出計數。將排程器測試放進一般單元測試目標,再為實際操作補上取消與資源釋放檢查。只要能穩定證明槽位、順序與收尾這三個邊界,換到另一台雲端 Mac 時,就有可複核的行為基準。
常見問題
替每個任務加入延遲,就能限制同時執行數量嗎?
不能。延遲只改變開始時間,不保證執行中的任務數量。應先提交上限數量,每完成一個再補入一個。
任務群組取消後會立刻返回嗎?
不保證。結構化任務群組離開前會等待子任務結束,因此操作必須配合取消,並結束底層工作。