Codebases accumulate patterns over time that don’t match up with current best practices. These patterns might have made sense when they were written, or they might just reflect how our understanding has evolved. Before you can address these patterns, you need to spot them and understand why they’re risky.
One particularly dangerous combination in iOS codebases is mixing locks (@synchronized, NSLock, etc.) with Core Data’s performAndWait. These patterns were used to maintain synchronous operation patterns, but together they create hidden cross-thread dependencies that lead to deadlocks, freezing your app.
This shows exactly how these deadlocks occur, so you can recognize and avoid them in your own code.
A Simple Shared Class
Let’s start with a basic class that manages some shared state. This shows a common pattern from before Swift concurrency – using dispatch queues to manage background work. This class might be accessed from multiple threads:
- Main thread: reads the operation status description
- Background thread: Starts a background operation
class DataProcessor {
var currentOperationIdentifier: String = ""
var currentOperationStatus: String = ""
// Called from main thread
func getDescription() -> String {
return "Operation \(currentOperationIdentifier) has status: \(currentOperationStatus)"
}
// Called from background thread
func startBackgroundOperation() {
currentOperationIdentifier = "DataSync"
currentOperationStatus = "Processing"
// Do processing
}
}The Problem – Race Conditions
When dealing with multiple threads, execution can interleave unpredictably. One thread executes some code, then another thread slips in and executes its code, then back to the first thread – you have no way of knowing the order.
Here’s what can happen:
| Background Thread | Main Thread |
| currentOperationIdentifier = “DataSync” | |
| (about to update status…) | |
| getDescription() | |
| reads identifier → “DataSync” ✓ | |
| reads status → “Idle” ❌ (old value!) | |
| currentOperationStatus = “Processing” ❌ too late – main thread already read old value |
The main thread ends up with the new identifier but the old status – a mismatch that leads to inconsistent data.
There are better solutions to this problem – like bundling related state in one immutable structure, or using actors in modern Swift. But in legacy codebases, synchronous locks were a common strategy to protect shared state.
Adding Locks for Thread Safety
The lock creates “critical sections” – ensuring we either write to both properties OR read from both without other threads interfering.
class DataProcessor {
private let lock = NSLock()
var currentOperationIdentifier: String = ""
var currentOperationStatus: String = ""
func getDescription() -> String {
lock.lock()
defer { lock.unlock() }
return "Operation \(currentOperationIdentifier) has status: \(currentOperationStatus)"
}
func startBackgroundOperation() {
lock.lock()
defer { lock.unlock() }
currentOperationIdentifier = "DataSync"
currentOperationStatus = "Processing"
}
}So far, this works fine. The locks protect our shared state, and both threads can safely access the properties.
The Deadlock – When Locks Meet Core Data
Now let’s assume we want to store this data to Core Data. This is where things get interesting.
When sharing Core Data across threads, you can run into race conditions just like we had earlier. So you need to use the right APIs to protect the critical sections too.
Your go-to is performBlock – it asynchronously performs the work safely. However, there are cases in legacy code where the caller needs to do something synchronously using performAndWait. When you call performAndWait on a main queue context, it blocks the calling thread until the block executes on the main thread. Think of waiting on the main queue as our “lock”.
Let’s assume some developer in the past (who definitely isn’t you) decided to use performAndWait here:
func startBackgroundOperation(with context: NSManagedObjectContext) {
lock.lock()
defer { lock.unlock() }
// Assume the main thread tries to call
// getDescription() at this point.
// It is blocked as we are holding the lock
currentOperationIdentifier = "DataSync"
currentOperationStatus = "Processing"
// 💀 DEADLOCK HAPPENS HERE
context.performAndWait {
saveDataToStore(context: context)
}
}Why Does This Deadlock?
There’s a problem:
performAndWaitneeds the MAIN THREAD to execute this block- The MAIN THREAD is blocked waiting for our lock (in
getDescription) - We’re holding that lock and won’t release until
performAndWaitcompletes
CIRCULAR WAIT = DEADLOCK
Timeline of the Deadlock
| Background Thread | Main Thread |
| lock.lock() ✅ | |
| Updates properties | Calls getDescription() |
| Still holding lock… | lock.lock() ❌ WAITING… |
| Waiting on performAndWait() (needs main thread) | Can’t process – stuck waiting on lock! |
- Main thread: stuck in
lock.lock()waiting for background thread - Background thread: stuck in
performAndWaitwaiting for main thread
How to Fix This Deadlock
The best solution is to eliminate performAndWait entirely and use the asynchronous perform instead. This breaks the circular dependency because the background thread no longer waits for the main thread:
func startBackgroundOperation(with context: NSManagedObjectContext) {
lock.lock()
defer { lock.unlock() }
currentOperationIdentifier = "DataSync"
currentOperationStatus = "Processing"
// ✅ No deadlock
// doesn't block waiting for main thread
context.perform {
self.saveDataToStore(context: context)
}
}If you absolutely cannot eliminate performAndWait, you’ll need to carefully analyze all lock dependencies, but this is error-prone and hard to maintain. The real fix is embracing asynchronous patterns.
What We Learned
In this article, we’ve seen how mixing locks with Core Data’s performAndWait creates a classic deadlock scenario:
- Race conditions can occur when multiple threads access shared mutable state
- Locks were traditionally used to protect this shared state with critical sections
- performAndWait works like a lock but requiring the main thread to execute
- When a background thread holds a lock and calls
performAndWait, while the main thread is waiting for that same lock, we get a circular dependency – neither thread can proceed
Coming Up
Future articles will explore other ways you can hit or avoid these deadlocks:
- Child contexts with read operations – Why using a child context doesn’t save you from deadlocks during fetch operations
- Child contexts with write operations – How save operations on child contexts create the same circular dependencies
- Private Contexts – Why private contexts with direct store connections are less likely to lock up




