圖片處理工作依序下載、轉換,最後寫入目錄。下載失敗後,轉換工作卻仍被排程執行;使用者按下取消,檔案卻還在繼續寫入。這兩種現象通常不是佇列失靈,而是把「相依工作完成」、「業務成功」和「取消要求」混為一談。本文在雲端 Mac 上透過獨立的 Swift 程式重現這些邊界情況,再將檢查移入 iOS 測試目標,不需要網路或簽署憑證。
先釐清佇列不負責什麼
OperationQueue 的相依關係只要求前置作業進入完成狀態,並不會理解下載失敗、轉換失敗等業務結果。cancel() 也只是設定取消旗標,不會強制終止正在執行的閉包。
為每個業務步驟定義成功、失敗、略過和取消四種終止狀態。相依工作失敗時,將後續步驟記錄為略過;已開始執行的步驟收到取消要求後,則在安全邊界退出。不要用同一個布林值同時表達「未執行」和「執行失敗」。
isFinished表示作業的生命週期已結束,不代表業務成功;isCancelled表示已收到取消要求,不代表工作已停止。
本例只使用同步的 BlockOperation。如果閉包啟動非同步請求後立即返回,佇列就會視為閉包已完成,無法再透過這裡的相依關係等待請求回呼。非同步作業需要另外實作正確的生命週期狀態。
以明確結果阻止失敗後繼續執行
將以下內容儲存為 QueueContract.swift。透過鎖保護結果的讀寫,避免日後新增並行步驟時產生資料競爭;本範例不依賴工作排程碰巧形成的執行順序。
import Foundation
enum Outcome: Equatable {
case pending
case succeeded
case failed
case skipped
}
final class State: @unchecked Sendable {
private let lock = NSLock()
private var values: [String: Outcome] = [:]
func set(_ key: String, _ value: Outcome) {
lock.lock()
defer { lock.unlock() }
values[key] = value
}
func get(_ key: String) -> Outcome {
lock.lock()
defer { lock.unlock() }
return values[key] ?? .pending
}
}
let state = State()
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 2
let download = BlockOperation {
state.set("download", .failed)
}
let convert = BlockOperation {
guard state.get("download") == .succeeded else {
state.set("convert", .skipped)
return
}
state.set("convert", .succeeded)
}
convert.addDependency(download)
queue.addOperations([convert, download], waitUntilFinished: true)
precondition(state.get("download") == .failed)
precondition(state.get("convert") == .skipped)
precondition(download.isFinished && convert.isFinished)
在終端機編譯並執行:
xcrun swiftc QueueContract.swift -o QueueContract
./QueueContract
結束碼為零就表示斷言通過,不必另外印出成功訊息。將下載結果改為成功後,也要相應地斷言轉換成功,形成正反兩個測試案例。@unchecked Sendable 只是開發者對執行緒安全的手動保證;這項保證成立的前提是所有共用狀態的存取都經過鎖保護,不能將內部字典暴露出去。
分開測試排隊中與執行中的取消
先驗證尚未執行的工作。將以下片段附加至同一個檔案,再重新編譯;使用獨立佇列,避免影響前一個測試案例。
let heldQueue = OperationQueue()
heldQueue.isSuspended = true
let cancelledState = State()
let waiting = BlockOperation {
cancelledState.set("body", .succeeded)
}
heldQueue.addOperation(waiting)
waiting.cancel()
heldQueue.isSuspended = false
heldQueue.waitUntilAllOperationsAreFinished()
precondition(waiting.isCancelled)
precondition(waiting.isFinished)
precondition(cancelledState.get("body") == .pending)
暫停佇列可確保取消發生在執行之前,不必猜測要等待幾毫秒。斷言同時檢查取消旗標、最終完成狀態,以及閉包未執行;少了任何一項,都會留下測試盲點。
執行中的工作則要另建測試案例:工作進入處理迴圈後,先發出「已開始」訊號;測試收到訊號後才呼叫取消,接著才允許下一輪迭代繼續。每一輪都要在讀取資料之前、提交結果之前檢查 isCancelled。等待訊號時必須設定逾時,逾時就判定失敗,不能讓測試無限期卡住。
取消前尚未被排程執行的閉包,不會替你寫入業務終止狀態,因此應由工作編排層補記為取消。不要要求一個根本沒執行的閉包負責清理。
寫入結果時保留提交邊界
協作式取消只能在檢查點生效。如果單次轉換耗時較長,取消延遲至少可能涵蓋目前這個轉換步驟;將耗時作業拆成合理的批次,比在空迴圈中頻繁輪詢更有意義。
先將檔案寫入該工作專用的暫存路徑,關閉檔案控制代碼後再提交。失敗或取消時,只清理自己的暫存檔,不要刪除共用目錄。若提交成功後才收到取消要求,應保留「成功」的終止狀態,而不是事後改成取消,否則呼叫端會誤以為沒有產出檔案。
取消與提交可能同時發生。若業務要求兩者嚴格互斥,就讓取消決策與提交決策都經過同一把鎖,或由同一個序列化狀態機處理。只在寫入前檢查一次旗標,並無法消除隨後發生的競爭情況。
納入回歸測試時檢查完整終止狀態
將範例斷言移入 XCTest 時,請以測試斷言取代 precondition。在雲端 Mac 的命令列工作中等待佇列完成沒有問題;但不要在應用程式的主執行緒上同步等待,尤其不能讓工作反過來等待主執行緒的回呼。
每輪至少涵蓋:前置工作成功、前置工作失敗、執行前取消、執行中取消、提交後取消。每一項都要同時檢查業務終止狀態、產出檔案是否存在,以及完成通知的次數。完成通知只能發送一次;未收到回呼或收到重複回呼,都應判定失敗。
一律使用可控制的訊號安排先後順序,不要用 sleep 製造所謂的穩定時機。最後要建立的是可重複驗證的契約:相依工作失敗時不會誤提交,取消後能結束,結束後才清理。先確保這些條件成立,才有可靠的基礎提高並行度。
常見問題
前置 Operation 失敗後,相依工作會自動略過嗎?
不會。相依關係只限制執行順序。必須透過執行緒安全的結果狀態傳遞失敗,再由後續工作明確決定略過。
呼叫 cancelAllOperations 後,可以立刻刪除工作檔案嗎?
不可以。執行中的工作可能仍在存取檔案。應先要求取消,讓工作協作結束,再於主執行緒之外等待完成,最後才清理檔案。