云端 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

从阅读走到第一次构建

准备好工作负载,再选择适合自己的独享物理节点。