How do Operation dependencies, cancellation and states interact, and what happens to a dependent operation when its dependency is cancelled?
Predict first
What is the order, and does a dependent of a cancelled operation run?
import Foundation
let q = OperationQueue(); q.maxConcurrentOperationCount = 4
let lock = NSLock()
nonisolated(unsafe) var log: [String] = []
func add(_ s: String) { lock.lock(); log.append(s); lock.unlock() }
let a = BlockOperation { Thread.sleep(forTimeInterval: 0.1); add("A") }
let b = BlockOperation { add("B") }
let c = BlockOperation { add("C") }
b.addDependency(a); c.addDependency(b)
q.addOperations([c, b, a], waitUntilFinished: true)
print(log.joined(separator: ","))
let q2 = OperationQueue(); q2.maxConcurrentOperationCount = 1; q2.isSuspended = true
nonisolated(unsafe) var ran: [String] = []
let x = BlockOperation { ran.append("x") }
let y = BlockOperation { ran.append("y") }
y.addDependency(x)
q2.addOperations([x, y], waitUntilFinished: false)
x.cancel()
q2.isSuspended = false
q2.waitUntilAllOperationsAreFinished()
print("ran:", ran, "x cancelled:", x.isCancelled, "x finished:", x.isFinished, "y finished:", y.isFinished)
- A,B,C / ran: ["y"] x cancelled: true x finished: true y finished: true
- C,B,A / ran: [] x cancelled: true x finished: true y finished: false
- A,B,C / ran: ["x", "y"] x cancelled: true x finished: true y finished: true
Mental model. Cancelling a step in a recipe does not cancel the next step. The cook still moves on, and must look at the pan to see that the earlier step never happened.
Dependencies are a directed graph of prerequisites: an operation becomes ready only when all its dependencies have finished, regardless of the order you add them. In the check the operations are added as C, B, A but run A, B, C because B depends on A and C on B, and no cycles are allowed (a cycle means none of them ever becomes ready). The queue’s maxConcurrentOperationCount still applies on top; 1 makes it serial.
The surprise is cancellation. Cancelling an operation does not remove it or its dependents: the cancelled operation is still “started” by the queue, skips its work (a block operation checks the flag before running the block), and moves to isFinished == true. Because a dependency that finished (cancelled or not) satisfies its dependents, the dependent does run: in the check x is cancelled before it starts, x.isFinished is true, and y still runs. If y must not run after a failed prerequisite, check dependencies.contains { $0.isCancelled } at the top of y (and cancel it), or share a result object and read it. For long operations check isCancelled periodically; a custom asynchronous Operation must manage isExecuting and isFinished with the right KVO notifications, which is why most teams wrap async work with a task and a continuation instead (Q291).
Dependencies order the work; a cancelled prerequisite still finishes, so its dependent runs.
Common traps
- Assuming a cancelled dependency cancels its dependents. It only finishes; the dependents run.
- Creating a dependency cycle. No operation in the cycle ever becomes ready, and nothing reports it.
They might follow up with
- How do you pass data between operations? A shared result holder, or reading from the dependency in the dependent’s body.
- Can I add a dependency after the operation started? No: it only affects operations that have not started.
Related questions
- 10.11.5 How does quality of service propagate between queues and blocks, and why does it matter for priority…
- 10.11.2 What does DispatchQueue.concurrentPerform do, when is it better than a loop of async calls, and how do you…
- 10.8.1 In what order do task group results arrive, and how do you limit how many children run at once?
- 10.2.1 When do you use OperationQueue instead of GCD, and how do dependencies, state and cancellation work?
- 10.1.2 How do barriers, groups, semaphores and dispatch sources coordinate work?
← How does a DispatchSourceTimer differ from Timer, and what are the… How does quality of service propagate between queues and blocks, and… →