Thread Safety: Protecting Shared Mutable State

Build a systematic mental model for protecting mutable state shared across threads — locks, serial queues, atomics, @synchronized, dispatch barriers, immutability, and ownership — and learn how to detect races with Thread Sanitizer.

Nearly every hard-to-reproduce crash, corrupted data structure, or 'impossible' bug in a concurrent Objective-C codebase traces back to the same root cause: two or more threads reading and writing the same mutable state without coordination. GCD and NSOperation give you the tools to run work concurrently; this lesson is about the other half of the problem — making sure that concurrency does not silently corrupt your data. Rather than introducing new concurrency primitives, this lesson is a synthesis: it pulls together locks, serial-queue synchronization, atomic properties, @synchronized, and dispatch barriers you have partly seen before, gives each one a precise definition of what it does and does not guarantee, and adds the practical skills of thinking about state ownership and using Thread Sanitizer to catch what manual review misses.

Why Mutable Shared State Is the Root of the Problem

A data race occurs when two threads access the same memory location at the same time, at least one of the accesses is a write, and there is no synchronization establishing an order between them. If a piece of state is never mutated after creation, or if only one thread ever touches it, races are structurally impossible — the danger only exists at the intersection of 'shared' and 'mutable.' This is why the single most effective thread-safety strategy is often not a locking mechanism at all, but eliminating one side of that intersection: make the state immutable, or make it exclusively owned by one thread.

When elimination is not possible — a cache that many threads read and write, a mutable array collecting results from parallel workers, a counter incremented from multiple queues — you need synchronization: a mechanism that ensures conflicting accesses cannot happen at the same time, or that establishes a safe ordering between them. Objective-C and Foundation offer several such mechanisms, each with different performance characteristics, ergonomics, and failure modes, and choosing the right one for a given piece of state is a real design decision, not a box to check.

It is worth being precise about what 'unsafe' actually looks like at runtime, because it rarely looks like an obvious crash on the first run. A race might corrupt an NSMutableArray's internal bookkeeping only when two mutations land in the same few nanoseconds — something that might happen one time in ten thousand on your machine and never during casual testing, yet constantly in production under real load. This unpredictability is precisely why thread safety must be designed in deliberately rather than discovered through testing.

Locks: Mutual Exclusion

A lock (mutex) is the most direct tool: it guarantees that only one thread at a time can be inside the region of code it protects — the 'critical section.' A thread calls -lock, does its work on the shared state, then calls -unlock; any other thread that calls -lock while the first thread holds it blocks until the first thread releases it. Foundation's NSLock is the simplest such object, and it is covered in depth, alongside its recursive and condition-based cousins, in the next lesson on Foundation Threading Utilities.

Locks are conceptually simple but carry real costs: acquiring an uncontended lock is cheap, but a contended lock (multiple threads waiting) causes threads to block, context-switch, and potentially cause priority inversion if a low-priority thread holds a lock a high-priority thread needs. Locks also introduce the classic correctness hazards of manual synchronization — forgetting to unlock on an early-return path, deadlocking by acquiring two locks in inconsistent order across two call sites, or, as the next lesson explores in detail, deadlocking by re-acquiring a non-recursive lock from the same thread.

Serial Queues as a Synchronization Mechanism

As the earlier Concurrency lessons established, a serial dispatch queue only ever runs one block at a time, in submission order. This property makes a serial queue a perfectly valid alternative to an explicit lock: instead of protecting a mutable dictionary with an NSLock, you can require that every read and write to that dictionary happen only as a block submitted to one specific serial queue. Because the queue guarantees mutual exclusion between those blocks by construction, no two accesses can ever overlap — you get the same safety guarantee as a lock, expressed as a queueing discipline instead of an explicit acquire/release pair.

This approach — often described as 'queue-based synchronization' — tends to be more idiomatic and less error-prone in Objective-C than sprinkling explicit locks throughout a class, precisely because there is no unlock call to forget: you simply never touch the protected state from anywhere except blocks submitted to the designated queue. It composes naturally with dispatch_sync for cases where a caller needs the read or write to complete before continuing (for example, a synchronous getter), and with dispatch_async for fire-and-forget writes.

The tradeoff, covered in the earlier Concurrency: Synchronization lesson, is that a pure serial queue serializes reads against reads unnecessarily — two threads that only want to read the shared state still queue up behind each other one at a time, even though concurrent reads would be perfectly safe. That is precisely the gap dispatch barriers fill, discussed shortly.

Atomic Properties: What They Guarantee and What They Don't

Declaring a property atomic (or simply omitting nonatomic, since atomic is the default) tells the Objective-C runtime to generate a getter and setter that use an internal lock to guarantee the property's underlying storage is never read or written in a torn, half-updated state. Concretely: if thread A calls the setter while thread B calls the getter at the exact same instant, thread B is guaranteed to see either the fully-old value or the fully-new value, never a partially-overwritten pointer or a garbage in-between bit pattern. That is the entire guarantee, and it is a real, meaningful one — without it, a race between a getter and a setter on certain property types could, in principle, return a corrupted, invalid pointer.

Here is the misconception that catches almost every engineer new to this topic at least once: atomic does NOT make compound operations safe. Consider self.counter = self.counter + 1. Even if counter is atomic, this statement is really three separate operations — an atomic read, an addition, and an atomic write — and nothing prevents another thread from performing its own read-modify-write in between this thread's read and this thread's write. Both threads can read the same starting value, both compute value+1, and both write the same result back, silently losing one of the two increments. The property was never read or written in a torn state at any individual step — the atomicity guarantee held perfectly — and the code is still racy, because atomicity was never about protecting sequences of operations, only single reads and single writes in isolation.

This means atomic is neither necessary nor sufficient for most real thread-safety problems: it adds real (if modest) locking overhead on every access, and it does not protect any multi-step invariant spanning more than one property or more than one read/write. Many experienced Objective-C codebases default properties to nonatomic for performance and instead reach for an explicit lock, serial queue, or dispatch barrier around the actual compound operations that need protecting — treating atomic as a narrow, low-level guarantee about a single property's storage, not a general-purpose thread-safety solution.

@synchronized: Built-in Mutex on an Object

The @synchronized(obj) { ... } directive is Objective-C's built-in syntax for mutual exclusion: it acquires a recursive mutex associated with obj (maintained internally by the runtime, keyed by the object's identity) before executing the block, and releases it afterward, even if an exception is thrown inside the block — the runtime wraps the block in an implicit try/finally-like structure. This makes it convenient: no explicit lock object to allocate, and no manual unlock call that could be forgotten on an early return, since the compiler-generated cleanup handles that automatically.

The convenience comes at a real cost. @synchronized's runtime lookup of the per-object lock (via a global table keyed by the object pointer) is measurably slower than a dedicated NSLock or a dispatch queue for the same critical section, particularly under contention. It is also recursive by default — the same thread can re-enter a @synchronized(obj) block on the same obj without deadlocking itself — which is convenient but can also mask a design where a lock is being reacquired more often, and more broadly, than intended, silently allowing reentrant paths that a plain lock would have caught as a bug.

@synchronized is best reserved for simple, low-contention critical sections where code clarity matters more than raw throughput, or for quick-and-correct protection during prototyping. For hot paths executed frequently under real contention, a dedicated NSLock or a serial/barrier-based dispatch queue is typically the better-performing choice.

Dispatch Barriers Revisited

The earlier Concurrency: Synchronization lesson introduced dispatch barriers as the solution to the 'concurrent reads, exclusive writes' pattern: submitting a block with dispatch_barrier_async (or dispatch_barrier_sync) to a concurrent queue guarantees that block runs alone, with no other block on that queue executing before or during it, while ordinary (non-barrier) blocks on the same queue may still run concurrently with each other around it. This gives you a reader-writer lock built entirely out of GCD primitives, with no explicit lock object at all.

In the context of this lesson's broader toolkit, barriers are the answer to serial-queue synchronization's main weakness: if your shared state is read far more often than it is written — a common pattern for caches and configuration objects — funneling every access, reads included, through a fully serial queue wastes concurrency that a barrier-based concurrent queue would preserve. Reads become dispatch_sync blocks on the concurrent queue (running freely alongside each other), and writes become dispatch_barrier_async blocks (running exclusively). This is frequently the best-performing synchronization strategy for read-heavy shared state in Objective-C code, and it is worth revisiting the earlier lesson's example if the mechanics are not fresh.

Immutability and Ownership as Design-Level Strategies

The most underused thread-safety technique is not a synchronization primitive at all: it is designing the shared object to be immutable in the first place. An NSString, an NSNumber, or a custom value object that exposes no mutating methods and whose ivars are all set once at initialization and never changed again can be freely read from any number of threads simultaneously with zero synchronization, because there is no write to race against a read. Where GCD or NSOperation code naturally produces new immutable result objects — a processed image, a parsed model, a computed summary — passing those results between threads needs no locking whatsoever; only truly mutable, long-lived shared state needs the heavier tools above.

The second design-level strategy is thinking explicitly about ownership: which single thread or which single queue is responsible for a given piece of mutable state. Rather than reactively wrapping every access to a shared dictionary in a lock, a well-architected system often designates one serial queue as the sole owner of that dictionary — all reads and writes happen only as blocks submitted to that queue, and no other code touches the dictionary's memory directly, ever. This is really the serial-queue synchronization technique from earlier, but viewed as an architectural principle rather than a mechanical substitute for a lock: state has an owner, and access to it is mediated, by convention and by code review, exclusively through that owner.

Combining these two strategies — favor immutable objects for anything that gets handed between threads, and give genuinely mutable state a single clear owning queue or thread — eliminates the vast majority of situations where you would otherwise need to reach for NSLock, @synchronized, or barriers at all. The remaining cases, where truly shared mutable state cannot be avoided, are exactly where the mechanical tools covered above earn their keep.

Race Detection and Thread Sanitizer

Data races have a distinctive and frustrating signature: they are intermittent. A race might never manifest on a developer's machine during manual testing, because the specific interleaving of instructions needed to expose it depends on timing that varies with CPU load, thread scheduling decisions, and even compiler optimization levels — yet the same code might crash reliably for real users under production load, or fail only on certain hardware. Symptoms range from outright crashes (accessing deallocated or corrupted memory), to silently wrong results (a lost increment, a stale cached value), to corrupted collection internals that crash much later, far from the actual racing code, making the bug appear to be somewhere else entirely.

Because races are so hard to catch through ordinary testing and code review, Xcode ships Thread Sanitizer (TSan), a runtime instrumentation tool you enable in a scheme's diagnostics settings. When enabled, TSan tracks every memory access and every synchronization event (lock acquisitions, queue submissions, atomic operations) during a test run, and it can detect an actual data race the moment two conflicting accesses occur without an established happens-before relationship between them — even if, by sheer luck, the two accesses did not visibly corrupt anything that particular run. This is a fundamentally different and stronger guarantee than what manual testing can offer: TSan does not need the race's consequences to be visible to catch the race itself.

Running Thread Sanitizer regularly — ideally as part of a CI test suite, not just occasionally by hand — catches classes of bugs that are otherwise nearly impossible to find through code review or manual QA, precisely because those bugs are only present under specific timing that a human tester is unlikely to reproduce on demand. The overhead is real (instrumented builds run slower and use more memory, so TSan is a testing-time tool, not something shipped to users), but for any codebase with real concurrent mutable state, that overhead is a small price for catching races before they reach production rather than after.

Synchronization Mechanisms at a Glance

atomic protects single property accesses only, never multi-step read-modify-write sequences. @synchronized is recursive by default; NSLock is not (see the next lesson). Barriers give concurrent reads with exclusive writes on the same queue.

// NSLock
NSLock *lock = [[NSLock alloc] init];
[lock lock];
// critical section
[lock unlock];

// Serial queue as synchronization
dispatch_queue_t stateQueue = dispatch_queue_create("com.app.state", DISPATCH_QUEUE_SERIAL);
dispatch_async(stateQueue, ^{ /* mutate shared state */ });

// Atomic property (default; guarantees only non-torn single access)
@property (atomic, strong) NSString *name;

// @synchronized
@synchronized (self) {
    // critical section
}

// Barrier on a concurrent queue (reader-writer pattern)
dispatch_barrier_async(concurrentQueue, ^{ /* exclusive write */ });

Code Examples

Marking value atomic guarantees each individual read of self.value and each individual write of self.value is never torn. It does nothing to prevent two threads from both reading the same old value before either has written back the incremented result -- the read-modify-write sequence as a whole is still racy. Fixing this requires an actual lock, a serial queue, or an atomic compare-and-swap primitive around the whole increment, not just an atomic property.

self.value = self.value + 1;

Common mistake: Assuming atomic protects the entire increment operation rather than just each individual property access.

_storage is only ever touched from blocks submitted to _stateQueue. Writes use dispatch_async since the caller does not need to wait; the read uses dispatch_sync because the getter must return the value before the method returns. Because the queue is serial, no two of these blocks ever run concurrently, so _storage can never be read and written at the same instant -- the same guarantee a lock would give, with no explicit unlock to forget.

dispatch_queue_t _stateQueue;

Reads use ordinary dispatch_sync on the concurrent queue, so many readers can run at the same time. Writes use dispatch_barrier_async, which waits for any in-flight reads to finish, runs alone with no other block (read or write) executing concurrently with it, and only afterward lets subsequent reads proceed. This preserves read concurrency that the fully-serial SafeCache example above gives up.

dispatch_barrier_async(_queue, ^{ MutableProfile genuinely can be written after creation, so every access -- read and write alike -- goes through @synchronized to prevent races. ImmutableProfile sidesteps the whole problem: because displayName is set once at init and never changes again, there is no mutation to race against a read, so no synchronization mechanism is needed at all. Preferring the immutable shape wherever the domain allows it is usually the highest-leverage thread-safety decision you can make.

// there is nothing to race.

@interface Counter : NSObject
@property (atomic, assign) NSInteger value; // atomic, but NOT enough
@end

@implementation Counter
- (void)increment {
    // read, add, write -- three steps, not one atomic step
    self.value = self.value + 1;
}
@end

// Two threads calling -increment concurrently, 1000 times each,
// will very likely NOT end with value == 2000, despite `atomic`.
@interface SafeCache : NSObject
@end

@implementation SafeCache {
    NSMutableDictionary *_storage;
    dispatch_queue_t _stateQueue;
}

- (instancetype)init {
    if (self = [super init]) {
        _storage = [NSMutableDictionary dictionary];
        _stateQueue = dispatch_queue_create("com.app.safecache", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}

- (void)setObject:(id)object forKey:(NSString *)key {
    dispatch_async(_stateQueue, ^{
        self->_storage[key] = object;
    });
}

- (id)objectForKey:(NSString *)key {
    __block id result;
    dispatch_sync(_stateQueue, ^{
        result = self->_storage[key];
    });
    return result;
}

@end
@implementation FastCache {
    NSMutableDictionary *_storage;
    dispatch_queue_t _queue; // concurrent
}

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

- (id)objectForKey:(NSString *)key {
    __block id result;
    dispatch_sync(_queue, ^{
        result = self->_storage[key];
    });
    return result;
}

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

@end
// Mutable shared state needs synchronization:
@interface MutableProfile : NSObject
@property (nonatomic, strong) NSString *displayName; // can change after creation
- (void)updateDisplayName:(NSString *)name;
@end

@implementation MutableProfile
- (void)updateDisplayName:(NSString *)name {
    @synchronized (self) {
        self.displayName = name;
    }
}
- (NSString *)displayNameSafely {
    @synchronized (self) {
        return self.displayName;
    }
}
@end

// Immutable value needs none at all:
@interface ImmutableProfile : NSObject
@property (nonatomic, strong, readonly) NSString *displayName;
- (instancetype)initWithDisplayName:(NSString *)name;
@end
// Once constructed, ImmutableProfile can be read from any thread, any number
// of times, concurrently, with zero locking -- there is nothing to race.

Choosing a synchronization strategy

A practical decision path for protecting a given piece of shared state.

Is the state ever mutated after creation?
     NO  -> immutable object, no synchronization needed
     YES -> Is it read far more often than written?
              YES -> concurrent queue + dispatch barrier (reader/writer)
              NO  -> serial queue OR NSLock OR @synchronized
                      (serial queue: idiomatic, no unlock to forget)
                      (NSLock: fastest, manual lock/unlock discipline)
                      (@synchronized: simplest syntax, more overhead)

Important Rules

A race requires shared state, mutation, and no synchronization between conflicting accesses -- remove any one of the three and the race is impossible.

atomic guarantees a single property access is never torn; it never makes a multi-step read-modify-write sequence safe.

A serial queue gives the same mutual-exclusion guarantee as a lock, with no explicit unlock call to forget.

Dispatch barriers let reads run concurrently while making writes exclusive on the same concurrent queue.

Prefer immutable objects and clear state ownership over reactively adding locks everywhere.

Run Thread Sanitizer regularly in test builds; races that never crash in manual testing are exactly what it is designed to catch.

A shared analytics event buffer

A typical app collects analytics events from many places -- UI taps on the main thread, background network callbacks, background processing operations -- into one shared mutable buffer that periodically flushes to disk. Teams that reactively sprinkle @synchronized around every buffer access tend to accumulate subtle bugs as the buffer's API grows; teams that instead designate one serial queue as the buffer's sole owner, exposing only queue-hopping methods like -logEvent: and -flush, tend to keep the class correct even as new call sites are added, because the ownership discipline -- not a lock the next engineer might forget -- is what enforces safety.

Marking a property atomic and assuming that makes every operation on it thread-safe.

Use atomic only for its narrow guarantee (non-torn single access), and wrap compound operations in a lock, serial queue, or barrier.

Atomic properties only guarantee that an individual get or set is not torn; a read-modify-write sequence built from two atomic accesses is still a race, since another thread can interleave between the read and the write.

Wrapping every access to shared state in @synchronized(self) throughout a large class without measuring the cost.

Use @synchronized for simple, low-contention cases, and switch to a dedicated NSLock or a barrier-based queue for hot paths under real contention.

@synchronized's per-object lock lookup has measurable overhead compared to a dedicated lock or dispatch queue, and that overhead compounds in code paths called frequently under contention.

Funneling both reads and writes of a read-heavy cache through one fully serial queue.

Use a concurrent queue with dispatch_barrier_async for writes, letting reads run concurrently with each other.

A serial queue is correct but unnecessarily conservative for read-heavy state: it serializes reads against other reads even though concurrent reads are inherently safe, throwing away available parallelism a barrier-based design would preserve.

Only testing concurrent code manually and concluding it is race-free because nothing crashed.

Run the code under Thread Sanitizer regularly, ideally in CI, since races often only manifest under specific timing that manual testing rarely reproduces.

Data races are inherently intermittent; the absence of a crash during a handful of manual test runs says very little, while TSan can flag the underlying race even on a run where its effects happened not to be visible.

What is required for a data race to occur? A. Two threads reading the same memory location at the same time B. Shared memory, at least one write among the conflicting accesses, and no synchronization ordering the accesses C. Any use of NSMutableArray from more than one thread, ever D. A missing @synchronized block anywhere in the class

B. Shared memory, at least one write among the conflicting accesses, and no synchronization ordering the accesses — A race specifically requires shared state, at least one conflicting write, and the absence of synchronization establishing an order between the accesses. Concurrent reads alone, with no writes, are not a race.

Given self.value = self.value + 1; where value is declared atomic, why can concurrent calls still lose increments? A. atomic properties cannot hold NSInteger values B. The read, the addition, and the write are three separate steps; atomic only guarantees each individual read or write is untorn, not the whole sequence C. atomic properties are actually slower than nonatomic and cause data loss D. The self. syntax bypasses the atomic getter and setter entirely

B. The read, the addition, and the write are three separate steps; atomic only guarantees each individual read or write is untorn, not the whole sequence — atomic prevents a torn read or write of the property's storage, but a compound read-modify-write like this is still three separate steps; two threads can interleave between the read and the write and both compute the same stale result.

How does a serial dispatch queue provide the same guarantee as a lock for protecting shared state? A. It doesn't -- serial queues only affect execution order, not memory safety B. By running only one block at a time, in order, so no two accesses submitted to it can ever execute concurrently C. By copying the shared state into each block automatically D. By making all properties inside blocks atomic automatically

B. By running only one block at a time, in order, so no two accesses submitted to it can ever execute concurrently — A serial queue's defining property -- one block executing at a time -- is exactly mutual exclusion. Requiring all access to a piece of state to happen only within blocks on one serial queue gives the same safety a lock would, without an explicit unlock call.

What advantage does a dispatch barrier give over a plain serial queue for protecting shared state? A. Barriers make writes faster than reads B. Barriers eliminate the need for the queue to be concurrent C. Barriers let ordinary (non-barrier) reads run concurrently with each other, while still making writes exclusive D. Barriers remove the need to ever call dispatch_sync

C. Barriers let ordinary (non-barrier) reads run concurrently with each other, while still making writes exclusive — On a concurrent queue, non-barrier blocks (reads) can run alongside each other, while a barrier block (a write) runs alone with nothing else on the queue executing concurrently with it -- preserving read parallelism a fully serial queue would give up.

Why is an immutable object inherently thread-safe to share across threads with no synchronization at all? A. Because Objective-C automatically locks immutable objects B. Because immutable objects are always allocated on the stack C. Because there is no mutation to race against a read once the object is fully constructed D. Because immutable objects cannot be passed between threads in the first place

C. Because there is no mutation to race against a read once the object is fully constructed — A data race requires a write among the conflicting accesses. If an object is never mutated after construction, there is no write to race against any number of concurrent reads, so no synchronization is needed.

What makes Thread Sanitizer more effective at finding races than manual testing? A. It runs the app faster so more test cases fit in the same time B. It statically analyzes source code without ever running the program C. It instruments memory accesses and synchronization events at runtime and can flag an actual race even when that run's specific interleaving didn't visibly corrupt anything D. It automatically rewrites racy code to be thread-safe

C. It instruments memory accesses and synchronization events at runtime and can flag an actual race even when that run's specific interleaving didn't visibly corrupt anything — TSan tracks accesses and synchronization events during execution and can detect that two conflicting accesses lacked a happens-before relationship even if, by chance, no visible corruption occurred on that particular run -- something manual testing, which depends on visible symptoms, cannot do.

Take the atomic Counter example from this lesson, which is racy under concurrent -increment calls despite value being atomic. Rewrite Counter using (a) an NSLock, and separately (b) a dedicated serial dispatch queue, so that 1000 concurrent increments from two threads reliably total 2000. Explain in a comment why the atomic property attribute alone was insufficient in the original version. Hint: The fix in both versions must protect the entire read-modify-write sequence as one critical section, not just the final assignment to self.value.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.