雲端 Mac 上的 iOS 工作佇列相依失敗與取消測試

雲端 Mac 上的 iOS 工作佇列相依失敗與取消測試

圖片處理工作依序下載、轉換,最後寫入目錄。下載失敗後,轉換工作卻仍被排程執行;使用者按下取消,檔案卻還在繼續寫入。這兩種現象通常不是佇列失靈,而是把「相依工作完成」、「業務成功」和「取消要求」混為一談。本文在雲端 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 後,可以立刻刪除工作檔案嗎?

不可以。執行中的工作可能仍在存取檔案。應先要求取消,讓工作協作結束,再於主執行緒之外等待完成,最後才清理檔案。

為下一次建置準備雲端 Mac

依照工作負載選擇機型與租用週期。使用獨享 Apple Silicon 實體節點,並非虛擬機。

立即租用雲端 Mac

從閱讀到第一次建置

準備好工作負載,再挑選適合自己的獨享實體節點。