NSOperation and NSOperationQueue
Learn to model units of work as NSOperation objects and coordinate them with NSOperationQueue, using dependencies, cancellation, readiness, and completion blocks instead of hand-rolled GCD callback chains.
Earlier you built concurrent code directly on top of Grand Central Dispatch, submitting blocks to queues and coordinating them with barriers, groups, and semaphores. GCD is powerful, but it is fundamentally a queue of anonymous closures: once a block is enqueued, you have no object to point at, cancel, query for progress, or hand a name to. NSOperation and NSOperationQueue sit one layer above GCD and give you exactly that: work as a first-class, inspectable, cancellable, dependent object. Internally, NSOperationQueue is built on top of GCD (or a similar dispatch mechanism), so you are not giving up GCD's performance characteristics — you are gaining an object-oriented vocabulary for expressing relationships between units of work that would otherwise require nested blocks and manual bookkeeping.
NSOperation as a Unit of Work
An NSOperation represents a single, self-contained task. Unlike a GCD block, which exists only as a closure passed to dispatch_async, an NSOperation is a real object with identity: you can hold a reference to it, ask it questions (is it finished? is it cancelled? is it ready?), and pass it around your codebase as a value. The simplest way to get one is NSBlockOperation, which wraps a block exactly the way dispatch_async does, but gives that block an object wrapper with all of NSOperation's machinery attached.
The more powerful approach is to subclass NSOperation directly and override -main to contain your task's logic. A subclass can carry its own properties (input data, a delegate, a partial result), which makes it reusable in a way an ad hoc block never is — you can instantiate the same operation subclass many times with different inputs, in different parts of the app, and queue it wherever needed. This is the core philosophical difference from GCD: GCD blocks are ephemeral instructions; NSOperation objects are reusable, inspectable components.
When you override -main, the default (synchronous) contract is simple: by the time -main returns, the operation is considered finished. NSOperation itself handles marking isFinished and notifying the queue. This synchronous model covers the vast majority of use cases — anything that does its work entirely within the body of -main, blocking that thread until done, fits perfectly. Asynchronous operations, where the work outlives the -main call (for example, a network request with a callback), require extra care, which is covered later in this lesson.
NSOperationQueue and Concurrency Control
NSOperationQueue is the object you add operations to; it decides when and on which threads they actually run. Under the hood it dispatches work onto GCD's global concurrent queues, but it adds a crucial control point that raw GCD lacks: maxConcurrentOperationCount. Setting this property lets you cap how many operations from a given queue may execute simultaneously — set it to 1 and the queue behaves like a serial queue for your operations (though not literally the same underlying serial dispatch queue); leave it at the default NSOperationQueueDefaultMaxConcurrentOperationCount and the queue sizes concurrency to the system's own heuristics, similar to a concurrent GCD queue.
This makes NSOperationQueue an excellent fit for problems like 'process up to four images at a time, no more' — with GCD alone you would need a semaphore to throttle concurrency manually; with NSOperationQueue you set one property. You add operations to a queue with -addOperation: or, more idiomatically for block-based work, -addOperationWithBlock:, which internally wraps the block in an NSBlockOperation for you.
A queue also owns the lifecycle bookkeeping for every operation added to it: it watches each operation's readiness and finished state via KVO (discussed later in this lesson) and starts eligible operations automatically as capacity and dependencies allow. You never manually 'start the next operation' — the queue's scheduling loop does that for you once an operation's isReady flips to true and there is available concurrency budget.
Dependencies: Declaring a Task Graph
The single biggest practical advantage NSOperation has over raw GCD is -addDependency:. Calling [operationB addDependency:operationA] tells the queue that operationB must not begin executing until operationA has finished — regardless of which queue either operation ultimately runs on, and even across two different NSOperationQueue instances. This lets you describe a task graph declaratively: build all the operation objects first, wire up their dependency edges, then throw the whole set at a queue and let it figure out a valid execution order.
Compare this to the equivalent GCD approach, which typically means nesting dispatch_async calls inside completion handlers, or building a small hand-rolled dependency tracker with dispatch groups and semaphores. As the graph grows — three stages, five stages, a diamond-shaped dependency where two parallel steps both feed into a final step — the nested-block version becomes deeply indented and hard to read, while the dependency-based version stays flat: create operations, call addDependency: a few times, enqueue everything.
Dependencies compose with concurrency limits naturally. If operationC depends on both operationA and operationB, and the queue has room to run A and B in parallel, it will; C simply will not become ready until both finish, no matter how much queue capacity is free. This is exactly the kind of coordination that is fiddly to express correctly with raw GCD primitives and falls out for free once you think in terms of operation objects and edges between them.
Cancellation: Cooperative, Not Preemptive
Calling -cancel on an NSOperation does not stop it. It cannot — Objective-C has no safe mechanism for forcibly halting an arbitrary thread mid-instruction, because doing so could leave locks held, memory half-mutated, or invariants broken. What -cancel actually does is set the operation's isCancelled property to true and, if the operation has not started yet, allow the queue to skip running it entirely. If the operation is already executing, cancellation is purely advisory: the running code must itself check isCancelled at reasonable points and choose to stop.
This means every long-running or loop-based operation you write should periodically test self.isCancelled and return early when it becomes true, ideally leaving any partial state in a safe, resumable, or discardable condition. A tight loop processing ten thousand items should check isCancelled every iteration or every batch of iterations — checking only once at the very start defeats the purpose, since by the time the check happens the operation may already be mostly or entirely done with wasted work.
Cancellation propagates through dependency graphs in a specific way worth understanding precisely: cancelling an operation that has not yet started prevents it from ever running (the queue simply removes it from consideration once finished/cancelled), but it does NOT automatically cancel operations that depend on it. Those dependents will still become ready once the cancelled operation reports isFinished (cancellation causes an operation to finish quickly, typically by skipping its main body) — so a well-designed operation subclass should check isCancelled at the very top of -main and, if already cancelled, mark itself finished immediately without doing any work, while dependents that care should check the upstream operation's own isCancelled to decide whether to proceed meaningfully.
Readiness and the Queue's Scheduling Decisions
isReady answers a specific question: could this operation begin executing right now, as far as its own dependency and cancellation state is concerned? An operation becomes ready once every operation it depends on has finished (successfully or via cancellation) — dependencies are the primary factor. You can also override -isReady in a subclass to add your own readiness conditions, for example an operation that should not run until a network reachability flag is true, though this is an advanced and relatively rare customization.
Readiness is necessary but not sufficient for an operation to actually start: the owning queue must also have spare concurrency budget under maxConcurrentOperationCount, and the operation must have a non-nil queuePriority ordering that the queue's scheduler is willing to service next among currently-ready operations. In practice you rarely need to reason about this scheduling algorithm in detail; the important mental model is that isReady is a precondition the queue watches, and the queue acts on it automatically the moment it flips true.
Because readiness, execution, and finishing state are all observable, you can build UI or logging that reacts to an operation graph's progress purely by watching these flags change, without the operation objects themselves needing to know anything about who is watching them — a clean separation of concerns that raw block-based GCD code does not offer as naturally.
Completion Blocks
Every NSOperation has a settable completionBlock property: a block the operation runs after it transitions to finished, whether that finish came from normal completion or from cancellation. This gives you a lightweight way to react to a single operation's result without building a full custom subclass with delegate callbacks — set operation.completionBlock = ^{ ... } before enqueuing, and that block fires once the operation is done.
There is an important subtlety: completionBlock does not run on any particular guaranteed queue or thread by default — it typically runs on an internal queue used by the operation machinery, not necessarily the queue the operation itself ran on and not automatically the main thread. If your completion block touches UI, you must explicitly dispatch back to the main queue inside it, exactly as you would with a GCD-based async API's completion handler.
completionBlock composes well with dependencies: because a dependent operation only becomes ready after its dependencies finish, and finishing is what triggers completionBlock, you can use completion blocks purely for side effects (logging, telemetry, cache updates) while using addDependency: for actual sequencing of further work — keeping the 'what runs next' logic in the dependency graph rather than scattered across nested completion closures.
Asynchronous Operations: The Hard Part
The default NSOperation contract assumes synchronous work: -main runs, blocks its thread until the task is fully done, and returns; the operation infrastructure then marks isFinished for you automatically. This breaks down the moment your operation's real work is itself asynchronous — for example, kicking off a network request via an API that takes a completion handler and returns immediately. If you naively put that inside -main, main returns instantly, the operation instance reports itself finished, and the queue happily moves on to dependents — long before your network callback has actually fired. This is one of the most common and subtle bugs when adopting NSOperation.
The correct pattern is to override -isAsynchronous to return YES, and to take manual, KVO-compliant control over isExecuting and isFinished yourself instead of relying on the default synchronous bookkeeping. Concretely: override -start (not just -main) to check isCancelled first, set isExecuting to true (firing the correct KVO notifications), kick off the real asynchronous work, and then, only inside the eventual completion callback of that async work, set isExecuting to false and isFinished to true, in that order, each wrapped in willChangeValueForKey:/didChangeValueForKey: (or backing ivars with manual KVO) so external observers — critically, the owning NSOperationQueue itself — see the transitions correctly.
It is worth walking through why getting this wrong is so easy: if you forget to override -isAsynchronous, the queue still assumes synchronous semantics and will consider the operation finished as soon as -start/-main returns, regardless of what your custom isFinished logic says, because the queue's own concurrency accounting was written against the synchronous contract. And if you override isExecuting/isFinished as computed properties without also implementing +keyPathsForValuesAffectingIsExecuting-style KVO plumbing or manual willChange/didChange calls around your backing ivars, the queue's internal KVO observers silently miss the transition, and the operation appears to hang forever from the queue's perspective even though your work actually completed.
Because this pattern is fiddly and error-prone, many teams either wrap the underlying async API in a technique that blocks the operation's own thread using a semaphore (effectively making it synchronous again, at the cost of tying up a thread), or reach for a well-tested third-party or Foundation-provided asynchronous operation base class rather than reimplementing the isExecuting/isFinished dance from scratch each time. Knowing the correct manual pattern, however, is essential for understanding what such helpers are actually doing underneath.
KVO State and Comparison with GCD
NSOperation's state properties — isReady, isExecuting, isFinished, and isCancelled — are all Key-Value Observing compliant, exactly as described in the earlier Key-Value Observing lesson. This is not an incidental detail: it is the actual mechanism NSOperationQueue uses internally to track each operation's lifecycle. The queue attaches KVO observers to isFinished (to know when to remove the operation and reconsider dependents) and to isReady (to know when a previously-blocked operation can now be scheduled). When you write a fully custom asynchronous operation subclass, you are essentially participating in the same KVO contract the queue itself relies on — which is exactly why manual KVO correctness matters so much for custom async operations, as covered above. You can attach your own observers to these same key paths for progress UI, logging, or testing, using the identical addObserver:forKeyPath:options:context: pattern from that earlier lesson.
So how should you choose between NSOperation/NSOperationQueue and raw GCD for a given piece of work? GCD wins for simple, fire-and-forget background work with no need for cancellation, no relationships to other tasks, and no need to inspect or reuse the unit of work afterward — a single dispatch_async block is lighter-weight, has less object overhead, and is more direct to read for that case. NSOperation wins as soon as you need any of: task-graph dependencies between multiple units of work, real (cooperative) cancellation support, a reusable, subclassable, parameterizable unit of work you will instantiate repeatedly with different inputs, or KVO-based progress/state observation without writing your own observer plumbing on top of raw blocks.
A practical rule of thumb many experienced Objective-C engineers use: reach for GCD by default because it is simpler and lower ceremony, and reach for NSOperationQueue specifically when you catch yourself trying to track cancellation flags, build ad hoc dependency chains with nested blocks and dispatch groups, or wanting a named, inspectable object to represent a piece of in-flight work — at that point NSOperation is not just nicer, it is usually less code and fewer bugs than the equivalent hand-built GCD machinery.
NSOperation and NSOperationQueue Basics
maxConcurrentOperationCount = 1 approximates a serial queue for your operations. addDependency: must be called before the dependent operation is added to a queue and started. cancel only sets a flag; running code must check isCancelled itself.
// Block-based operation
NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"working");
}];
// Queue with concurrency limit
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
queue.maxConcurrentOperationCount = 4;
// Dependencies
[opB addDependency:opA];
// Completion block
op.completionBlock = ^{
NSLog(@"done");
};
// Adding work
[queue addOperation:op];
[queue addOperationWithBlock:^{ /* ... */ }];
// Cancellation (cooperative)
[op cancel]; // sets isCancelled
if (op.isCancelled) { /* stop cooperatively */ }Code Examples
Because this operation does all of its work synchronously inside -main, the default NSOperation machinery is sufficient: no isAsynchronous override is needed, and the operation is automatically marked finished the moment -main returns. Note the two isCancelled checks — one before starting expensive work, one after — so cancellation requested mid-run is honored without extra effort.
if (self.isCancelled) { return; } fetchUser and fetchSettings have no dependency on each other, so the queue runs them concurrently. renderDashboard depends on both and will not become ready — and therefore will not run — until both finish, regardless of execution order or timing. The completion block hops to the main queue before touching UI, since completionBlock does not run on the main thread by default.
[renderDashboard addDependency:fetchUser];
This subclass overrides -start (not -main), returns YES from isAsynchronous, and manually fires KVO notifications around isExecuting and isFinished via willChangeValueForKey:/didChangeValueForKey:. isFinished is only set to YES inside the async completion callback, so the owning queue correctly waits for the real work to finish before treating the operation as done and unblocking its dependents.
• (BOOL)isAsynchronous { return YES; } Common mistake: Omitting isAsynchronous or the manual KVO calls causes the queue to think the operation finished the instant -start returns, long before the network callback actually fires.
Because isFinished is KVO-compliant on every NSOperation, you can observe it directly with the same addObserver:forKeyPath:options:context: API from the Key-Value Observing lesson — this is literally the same mechanism NSOperationQueue itself uses internally to know when an operation is done and its dependents can proceed.
@interface ImageFilterOperation : NSOperation
@property (nonatomic, strong) UIImage *inputImage;
@property (nonatomic, strong, readonly) UIImage *outputImage;
@end
@implementation ImageFilterOperation {
UIImage *_outputImage;
}
- (void)main {
if (self.isCancelled) {
return; // finishes immediately, no work done
}
UIImage *result = [self applySepiaFilterTo:self.inputImage];
if (self.isCancelled) {
return; // discard partial result
}
_outputImage = result;
}
- (UIImage *)outputImage {
return _outputImage;
}
- (UIImage *)applySepiaFilterTo:(UIImage *)image {
// expensive pixel processing here
return image;
}
@endNSOperationQueue *queue = [[NSOperationQueue alloc] init];
NSBlockOperation *fetchUser = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"fetching user profile");
}];
NSBlockOperation *fetchSettings = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"fetching user settings");
}];
NSBlockOperation *renderDashboard = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"rendering dashboard with profile + settings");
}];
[renderDashboard addDependency:fetchUser];
[renderDashboard addDependency:fetchSettings];
renderDashboard.completionBlock = ^{
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"dashboard ready, update UI here");
});
};
[queue addOperations:@[fetchUser, fetchSettings, renderDashboard]
waitUntilFinished:NO];@interface AsyncNetworkOperation : NSOperation
@end
@implementation AsyncNetworkOperation {
BOOL _executing;
BOOL _finished;
}
- (BOOL)isAsynchronous { return YES; }
- (BOOL)isExecuting { return _executing; }
- (BOOL)isFinished { return _finished; }
- (void)setExecuting:(BOOL)executing {
[self willChangeValueForKey:@"isExecuting"];
_executing = executing;
[self didChangeValueForKey:@"isExecuting"];
}
- (void)setFinished:(BOOL)finished {
[self willChangeValueForKey:@"isFinished"];
_finished = finished;
[self didChangeValueForKey:@"isFinished"];
}
- (void)start {
if (self.isCancelled) {
[self setFinished:YES];
return;
}
[self setExecuting:YES];
[self performNetworkRequestWithCompletion:^{
[self setExecuting:NO];
[self setFinished:YES];
}];
}
- (void)performNetworkRequestWithCompletion:(void (^)(void))completion {
// hypothetical async API that calls back later, off this call stack
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(1); // stand-in for real network latency
completion();
});
}
@endImageFilterOperation *op = [[ImageFilterOperation alloc] init];
op.inputImage = someImage;
[op addObserver:self
forKeyPath:@"isFinished"
options:NSKeyValueObservingOptionNew
context:NULL];
[queue addOperation:op];
// elsewhere in the class:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
if ([keyPath isEqualToString:@"isFinished"] && [change[NSKeyValueChangeNewKey] boolValue]) {
NSLog(@"operation finished, output ready");
[object removeObserver:self forKeyPath:@"isFinished"];
}
}Operation dependency graph on a queue
Two independent operations run concurrently; a third becomes ready only once both dependencies finish.
[fetchUser] ----+
|--> [renderDashboard] --> completionBlock
[fetchSettings] --+
isReady(renderDashboard) becomes true only after
isFinished(fetchUser) AND isFinished(fetchSettings)Important Rules
Cancellation is cooperative: cancel sets isCancelled, but running code must check it and stop itself.
addDependency: must be set up before the dependent operation starts executing, typically before enqueuing.
A synchronous -main override needs no extra bookkeeping; the operation is marked finished automatically when main returns.
An asynchronous operation must override isAsynchronous, isExecuting, and isFinished, and drive them with correct KVO notifications.
completionBlock does not run on a guaranteed thread; dispatch to the main queue explicitly before touching UI inside it.
Multi-stage image processing pipelines
Apps that process user photos often chain several distinct stages — decode, resize, apply filters, generate a thumbnail, upload — where later stages need earlier ones' output and the whole pipeline should be cancellable if the user backs out of the screen. Modeling each stage as an NSOperation subclass with explicit addDependency: edges, and cancelling the entire chain with a single loop over the queue's operations (or -cancelAllOperations), gives a codebase far easier to reason about and to extend with new stages than an equivalent tower of nested GCD completion blocks would be.
Putting async network code directly inside -main without overriding isAsynchronous.
Override -start, isAsynchronous, isExecuting, and isFinished, and only mark isFinished inside the real completion callback.
Without this, the operation reports itself finished as soon as -main returns, long before the actual asynchronous work is done, so dependents run prematurely and completionBlock fires with a bogus 'done' signal.
Writing a long loop in -main that only checks isCancelled once, at the very top.
Check isCancelled inside the loop, at a reasonable interval, so a cancel request takes effect promptly.
Cancellation is purely cooperative — if the code never looks at isCancelled again after the initial check, calling -cancel has no practical effect once the operation has started running.
Expecting completionBlock to run on the main thread automatically.
Explicitly dispatch_async to the main queue inside completionBlock before touching any UI.
completionBlock executes on whatever thread the operation infrastructure happens to use, not necessarily the operation's own thread and not automatically the main thread; UI work performed there directly is a threading bug.
Calling addDependency: after the operation has already been added to a queue and started.
Build the full dependency graph before adding any of the operations to a queue.
Once an operation starts running or finishes, adding a new dependency to it or to something already executing has no meaningful effect on scheduling that has already happened.
What does calling -cancel on a running NSOperation actually do? A. It immediately terminates the operation's thread B. It sets isCancelled to true; the operation must check that flag itself and stop cooperatively C. It removes the operation from its queue instantly D. It throws an exception inside -main
B. It sets isCancelled to true; the operation must check that flag itself and stop cooperatively — Cancellation in NSOperation is cooperative, not preemptive. The framework cannot safely force-kill a running thread, so -cancel only flips isCancelled; the operation's own code is responsible for checking it and stopping.
Why does maxConcurrentOperationCount matter on NSOperationQueue? A. It sets the total number of operations the queue will ever accept B. It controls how many operations from that queue may execute simultaneously C. It determines the priority of the first operation added D. It is only relevant for NSBlockOperation instances, not custom subclasses
B. It controls how many operations from that queue may execute simultaneously — maxConcurrentOperationCount caps simultaneous execution from that queue, giving you throttling control (e.g., 'process at most 4 images at once') that plain GCD requires a manual semaphore to achieve.
An operation C depends on both A and B via addDependency:. When does C become ready? A. As soon as it is added to the queue B. As soon as either A or B finishes C. Only after both A and B have finished D. Only after A, B, and C are all added to the same queue and the queue is started
C. Only after both A and B have finished — isReady only becomes true once every dependency has finished. With two dependencies, both must complete — A and B — before C is eligible to run.
Why must a custom asynchronous NSOperation subclass override -start instead of relying on the default -main contract? A. Because -main cannot contain any network code B. Because the default contract assumes the operation is done as soon as -main returns, which is wrong when the real work finishes later via a callback C. Because -start is required for NSBlockOperation but optional for subclasses D. Because isCancelled cannot be read from within -main
B. Because the default contract assumes the operation is done as soon as -main returns, which is wrong when the real work finishes later via a callback — The synchronous default marks the operation finished the moment -main returns. For truly asynchronous work, you must override -start, set isAsynchronous to YES, and manually control isExecuting/isFinished so the queue waits for the real completion.
How does NSOperationQueue internally know when a dependency has finished and a dependent operation can now run? A. It polls each operation's isFinished property on a timer B. It relies on KVO notifications on the isFinished (and isReady) key paths, since NSOperation's state properties are KVO-compliant C. Each operation must call a queue delegate method manually D. It relies on NSNotificationCenter broadcasts
B. It relies on KVO notifications on the isFinished (and isReady) key paths, since NSOperation's state properties are KVO-compliant — NSOperation's isReady, isExecuting, isFinished, and isCancelled are all KVO-compliant, and the queue attaches its own KVO observers to these key paths to drive scheduling — the same mechanism covered in the Key-Value Observing lesson.
When is raw GCD generally the better choice over NSOperation/NSOperationQueue? A. Whenever any concurrency at all is needed B. For simple, fire-and-forget background work with no dependencies, no need for cancellation, and no need to inspect or reuse the task afterward C. Whenever cancellation support is required D. Whenever more than one unit of work needs to run
B. For simple, fire-and-forget background work with no dependencies, no need for cancellation, and no need to inspect or reuse the task afterward — GCD is lighter-weight and simpler for a single, self-contained background task. NSOperation earns its extra ceremony specifically when you need dependencies, cooperative cancellation, reusable/subclassable work objects, or KVO-based progress tracking.
Design (in comments/pseudocode is fine, but structure it as real NSOperation subclasses) a three-stage pipeline: DownloadOperation (async, fetches raw data), DecodeOperation (depends on DownloadOperation, synchronous, parses the data), and CacheOperation (depends on DecodeOperation, synchronous, writes the parsed result to disk). Wire the dependencies, add all three to one NSOperationQueue with maxConcurrentOperationCount = 2, and set a completionBlock on CacheOperation that logs success on the main queue. Hint: DownloadOperation needs the full isAsynchronous/isExecuting/isFinished KVO pattern since its real work outlives -start; the other two can rely on the default synchronous -main contract.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.