Concurrency

This section documents modern Objective-C as implemented by Apple Clang in the current Xcode release — Objective-C 2.0 plus ARC, literals and subscripting, instancetype, Clang modules, lightweight generics, nullability and @available — as published at Apple Developer Documentation and the Clang Objective-C specifications, which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against developer.apple.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Objective-C offers three layers of concurrency, and modern code lives almost entirely in the middle one. Threads (NSThread) are the raw primitive and are rarely used directly. Grand Central Dispatch (GCD) is the block-based queue API that most concurrent Objective-C is written against. NSOperation is an object-oriented layer above GCD that adds dependencies, cancellation and priorities.

The rule that governs everything below: all UI work happens on the main thread, and nothing else should.

Threads

// Explicit threads -- shown for completeness, rarely the right tool.
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(work) object:nil];
thread.name = @"com.example.worker";
thread.qualityOfService = NSQualityOfServiceUtility;
[thread start];

- (void)work {
    @autoreleasepool {          // a secondary thread has no pool of its own
        …
    }
}

// Querying
[NSThread isMainThread];
[NSThread currentThread];
[NSThread sleepForTimeInterval:0.5];      // never do this on the main thread

The Main Thread

UIKit and AppKit are not thread-safe, and touching a view from a background thread produces corruption or a crash — sometimes much later, which is what makes it so unpleasant to debug.

// GCD -- the modern way to get back to the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
    self.label.text = result;
});

// The older Foundation spelling, still seen:
[self performSelectorOnMainThread:@selector(updateUI:) withObject:result waitUntilDone:NO];

// An assertion worth keeping in any method that must be on the main thread:
NSAssert([NSThread isMainThread], @"UI update off the main thread");

The Main Thread Checker (on by default in Xcode’s debug scheme) catches most violations automatically — see Build and Tooling.

Synchronisation

@synchronized

A recursive mutex keyed on an object — the simplest lock in the language:

- (void)addItem:(Item *)item {
    @synchronized (self) {
        [_items addObject:item];
    }
}

It is convenient, it is exception-safe, and it is slow relative to the alternatives. Lock on a dedicated private object rather than self where contention matters, so that external code locking on your object cannot interfere:

@synchronized (_lockToken) { … }

NSLock and Friends

@property (nonatomic, strong) NSLock *lock;

[self.lock lock];
… critical section …
[self.lock unlock];

if ([self.lock tryLock]) { … [self.lock unlock]; }      // non-blocking attempt
[self.lock lockBeforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]];
Class Use

NSLock

A plain mutex. Not recursive — locking twice on one thread deadlocks.

NSRecursiveLock

Safe to lock repeatedly from the same thread.

NSCondition

A lock plus wait/signal, for producer-consumer patterns.

NSConditionLock

A lock associated with an integer state.

All of these are harder to use correctly than a serial dispatch queue, because there is no scope-based release: an early return or a thrown exception between lock and unlock leaves the lock held. Prefer a queue.

Why atomic Is Not Thread Safety

An atomic property guarantees only that a single get or set is indivisible — you can never read a half-written pointer. It says nothing about anything larger:

@property (atomic, strong) NSMutableArray *items;

// STILL a race, despite `atomic`:
if (self.items.count > 0) {              // atomic read of the pointer
    id first = self.items[0];            // another thread may have emptied it in between
}

// Also still a race -- read-modify-write is three operations:
self.counter = self.counter + 1;

// And `atomic` protects the POINTER, not the object it points at:
[self.items addObject:item];             // completely unprotected

atomic also costs performance on every access. Because it buys so little, nonatomic is the universal convention and real safety comes from a queue, a lock, or immutability. See Properties and Encapsulation.

Grand Central Dispatch

Queues

// The main queue -- serial, and bound to the main thread.
dispatch_queue_t main = dispatch_get_main_queue();

// A global concurrent queue at a given quality of service.
dispatch_queue_t global = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);

// Your own serial queue -- the workhorse for protecting shared state.
dispatch_queue_t serial = dispatch_queue_create("com.example.serial", DISPATCH_QUEUE_SERIAL);

// Your own concurrent queue.
dispatch_queue_t concurrent = dispatch_queue_create("com.example.concurrent", DISPATCH_QUEUE_CONCURRENT);

A serial queue runs one block at a time in order — which is what makes it a lock substitute. A concurrent queue may run many at once.

dispatch_async and dispatch_sync

// Asynchronous: enqueue and return immediately. The common case.
dispatch_async(global, ^{
    NSData *data = [self expensiveComputation];
    dispatch_async(dispatch_get_main_queue(), ^{
        [self display:data];
    });
});

// Synchronous: enqueue and WAIT. Use sparingly.
__block NSArray *snapshot;
dispatch_sync(serial, ^{
    snapshot = [_items copy];
});

Never call dispatch_sync on the queue you are already running on — it deadlocks instantly. The classic crash is dispatch_sync(dispatch_get_main_queue(), …) from the main thread.

Quality of Service

QoS tells the system how to prioritise the work, which affects CPU scheduling, I/O priority and energy use:

Class For

QOS_CLASS_USER_INTERACTIVE

Work that must complete this frame. UI rendering; keep it tiny.

QOS_CLASS_USER_INITIATED

The user asked for it and is waiting. Hundreds of milliseconds.

QOS_CLASS_DEFAULT

Unspecified.

QOS_CLASS_UTILITY

Long-running work with a progress indicator. Seconds to minutes.

QOS_CLASS_BACKGROUND

Invisible maintenance — prefetching, indexing, syncing. Minutes or more.

Choosing accurately matters on battery-powered devices; over-prioritising everything defeats the mechanism.

Groups

Wait for a set of independent tasks to finish:

dispatch_group_t group   = dispatch_group_create();
dispatch_queue_t collect = dispatch_queue_create("collect", DISPATCH_QUEUE_SERIAL);

for (NSURL *url in urls) {
    dispatch_group_enter(group);                   // manual enter/leave for async work
    [self fetchURL:url completion:^(NSData *data, NSError *error) {
        // Completion handlers run concurrently, and NSMutableArray is not
        // thread-safe -- funnel the mutation through one serial queue.
        dispatch_async(collect, ^{
            [results addObject:data ?: [NSNull null]];
            dispatch_group_leave(group);           // leave AFTER the append, and on EVERY path
        });
    }];
}

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    [self allDownloadsFinished:results];           // runs when the count returns to zero
});

dispatch_group_async handles the simpler case where the work is itself synchronous; dispatch_group_enter/ leave is what you need for callback-based APIs. An unbalanced leave crashes; a missing one hangs.

dispatch_group_wait blocks the current thread until the group empties — never call it on the main thread.

Semaphores

A counting semaphore, most often used to limit concurrency:

// At most 4 downloads in flight at once.
dispatch_semaphore_t limit = dispatch_semaphore_create(4);

// The loop itself must run off the main queue: dispatch_semaphore_wait blocks
// *the calling thread*, so driving this from the main thread freezes the UI
// as soon as 4 downloads are in flight.
dispatch_async(global, ^{
    for (NSURL *url in urls) {
        dispatch_semaphore_wait(limit, DISPATCH_TIME_FOREVER);
        dispatch_async(global, ^{
            [self downloadSynchronously:url];
            dispatch_semaphore_signal(limit);
        });
    }
});

Using one to make an asynchronous API synchronous (wait on the calling thread, signal in the callback) is a well-known anti-pattern: it blocks a thread, risks deadlock if the callback is delivered to that same queue, and can exhaust the thread pool. Restructure with a completion block instead.

dispatch_once

Guaranteed-once initialisation, thread-safe and essentially free after the first call. The canonical singleton:

+ (instancetype)sharedInstance {
    static MyManager *shared = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        shared = [[self alloc] init];
    });
    return shared;
}

The dispatch_once_t must be static or a global — never a local, an ivar, or anything else in heap storage. dispatch_once may leave its predicate in a transient state that is only safe in memory which is never freed or reused, so a per-instance predicate is unsupported; it would also quietly turn "once" into "once per instance". For per-instance lazy initialisation use a lock or a serial queue instead.

Barriers

On a concurrent queue you own, a barrier block waits for everything already enqueued to finish, runs alone, and only then lets subsequent blocks proceed. That gives the reader-writer pattern: concurrent reads, exclusive writes.

@implementation ThreadSafeStore {
    NSMutableDictionary *_store;
    dispatch_queue_t _queue;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _store = [NSMutableDictionary dictionary];
        _queue = dispatch_queue_create("com.example.store", DISPATCH_QUEUE_CONCURRENT);
    }
    return self;
}

- (id)objectForKey:(NSString *)key {
    __block id value;
    dispatch_sync(_queue, ^{ value = self->_store[key]; });      // concurrent read
    return value;
}

- (void)setObject:(id)object forKey:(NSString *)key {
    dispatch_barrier_async(_queue, ^{ self->_store[key] = object; });   // exclusive write
}

@end

Barriers only work on a queue you created as DISPATCH_QUEUE_CONCURRENT — on a global queue they behave like an ordinary dispatch_async.

Dispatching Back to the Main Queue

sequenceDiagram participant M as Main queue
(UI thread) participant G as Global queue
(QOS_CLASS_USER_INITIATED) participant N as Network / disk M->>M: user taps "Refresh" M->>M: show the spinner (UI work -- main queue only) M->>G: dispatch_async(global, ^{ ... }) Note over M: The main thread returns immediately.
The UI stays responsive. G->>N: synchronous fetch + parse N-->>G: bytes G->>G: expensive parsing off the main thread G->>M: dispatch_async(dispatch_get_main_queue(), ^{ ... }) Note over G: The background block ends here. M->>M: hide the spinner M->>M: reload the table view Note over M: Every UIKit/AppKit call happens here.
Touching a view from G would corrupt or crash.

NSOperation and NSOperationQueue

NSOperation wraps a unit of work as an object, adding what raw GCD blocks lack: dependencies, cancellation, priorities and KVO-observable state.

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
queue.maxConcurrentOperationCount = 4;              // 1 makes it serial
queue.qualityOfService = NSQualityOfServiceUserInitiated;

NSBlockOperation *download = [NSBlockOperation blockOperationWithBlock:^{
    …
}];
NSBlockOperation *process = [NSBlockOperation blockOperationWithBlock:^{
    …
}];

[process addDependency:download];                   // process waits for download
[queue addOperations:@[ download, process ] waitUntilFinished:NO];

[queue cancelAllOperations];
[NSOperationQueue mainQueue];                       // the main queue, as an NSOperationQueue

A custom operation must check isCancelled itself — cancelling sets a flag, it does not interrupt running code:

@implementation ImportOperation

- (void)main {
    @autoreleasepool {
        for (Record *record in self.records) {
            if (self.isCancelled) { return; }       // cooperative cancellation
            [self import:record];
        }
    }
}

@end
Use GCD when Use NSOperation when

The work is fire-and-forget

You need cancellation

There are no dependencies

Tasks depend on one another

You want the lightest possible mechanism

You want to limit concurrency by count

A block expresses it fully

The work has state worth encapsulating in a class

NSRunLoop

A run loop is an event-processing loop that keeps a thread alive and dispatches input sources — timers, port messages, and the events UIKit and AppKit deliver.

NSRunLoop *runLoop = [NSRunLoop currentRunLoop];

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 repeats:YES block:^(NSTimer *t) { … }];
[runLoop addTimer:timer forMode:NSRunLoopCommonModes];

[runLoop run];                                   // blocks, processing events

The main thread’s run loop is created and run for you. You need one on a secondary thread only if that thread must service timers or ports; a dispatch queue is almost always the better answer.

The detail that bites in practice is run loop modes: a timer added in the default mode stops firing while the user scrolls, because scrolling switches the run loop to tracking mode. NSRunLoopCommonModes covers both.

Foundation Collection Thread-Safety

Mutable Foundation collections are not thread-safe. Immutable ones are safe to read concurrently, because nothing can change them.

The three workable approaches:

// 1. Immutability -- the simplest and the fastest to reason about.
@property (nonatomic, copy) NSArray<Item *> *items;      // replace wholesale, never mutate

// 2. A serial queue owning the mutable state.
dispatch_async(_queue, ^{ [self->_items addObject:item]; });

// 3. A concurrent queue with barriers -- concurrent reads, exclusive writes (above).

NSCache is the notable exception: it is thread-safe. See Collections and Fast Enumeration.

Finally, the Thread Sanitizer (-fsanitize=thread) finds data races that testing alone will not — run your test suite under it periodically.

See Also