Lock Ordering and Circular Dependencies

Deadlocks: Sync to Same Queue covered the classic queue reentry deadlock. This one's sneakier: two locks, two threads, wrong order.
Follow along with the code: iOS-Practice on GitHub
The Setup
A bank account with two locks—one for balance, one for transaction logging:
class BankAccount {
private let balanceLock = NSLock()
private let transactionLock = NSLock()
private var balance: Double = 1000.0
private var transactionHistory: [String] = []
func transfer(amount: Double, to other: BankAccount) {
balanceLock.lock()
defer { balanceLock.unlock() }
if balance >= amount {
logTransaction("Sending $\(amount)")
balance -= amount
other.receive(amount: amount, from: self)
}
}
private func logTransaction(_ message: String) {
transactionLock.lock()
defer { transactionLock.unlock() }
// Need to read balance for the log
balanceLock.lock() // đź’€ Already held by caller!
transactionHistory.append("\(message) - Balance: \(balance)")
balanceLock.unlock()
}
}
The Deadlock
Thread A calls transfer():
- Acquires
balanceLock - Calls
logTransaction() - Acquires
transactionLock - Tries to acquire
balanceLock... but Thread A already holds it
Even with a single thread, this deadlocks. The same thread tries to lock a non-recursive lock it already holds. Instant freeze.
With two threads doing concurrent transfers, you get the classic ordering problem:
- Thread A: holds
balanceLock, wantstransactionLock - Thread B: holds
transactionLock, wantsbalanceLock
Neither can proceed. Both wait forever.
Why Lock Ordering Matters
When code acquires multiple locks in different orders, you create a circular dependency. Thread A waits for what Thread B holds, and Thread B waits for what Thread A holds. The dependency graph forms a cycle with no escape.
The rule: Always acquire locks in the same order everywhere.
If every thread acquires balanceLock before transactionLock, no circular dependency can form. One thread may wait, but it will eventually proceed.
The Fix: Don't Nest Locks
The cleanest solution is to avoid holding multiple locks simultaneously:
func transfer(amount: Double, to other: BankAccount) {
let message: String
let canTransfer: Bool
balanceLock.lock()
if balance >= amount {
balance -= amount
message = "Sent $\(amount) - Balance: \(balance)"
canTransfer = true
} else {
message = "Insufficient funds"
canTransfer = false
}
balanceLock.unlock()
// Log after releasing balance lock
if canTransfer {
logTransaction(message)
other.receive(amount: amount, from: self)
}
}
private func logTransaction(_ message: String) {
transactionLock.lock()
defer { transactionLock.unlock() }
// No need for balanceLock - balance already in message
transactionHistory.append(message)
}
Key changes:
- Capture the balance while holding the lock
- Release the lock before calling other methods
logTransactionno longer needs to accessbalance
Alternative: Lock Ordering Convention
If you must hold multiple locks, establish a strict order:
// Convention: Always acquire balanceLock before transactionLock
func transfer(amount: Double, to other: BankAccount) {
balanceLock.lock()
transactionLock.lock()
defer {
transactionLock.unlock()
balanceLock.unlock()
}
if balance >= amount {
balance -= amount
transactionHistory.append("Sent $\(amount) - Balance: \(balance)")
other.receive(amount: amount, from: self)
}
}
Document the order. Enforce it in code review. Every method that touches these locks must follow the convention.
Alternative: Use Actors
Swift actors eliminate these problems entirely:
actor BankAccount {
private var balance: Double = 1000.0
private var transactionHistory: [String] = []
func transfer(amount: Double, to other: BankAccount) async {
guard balance >= amount else { return }
balance -= amount
transactionHistory.append("Sent $\(amount) - Balance: \(balance)")
await other.receive(amount: amount)
}
func receive(amount: Double) {
balance += amount
transactionHistory.append("Received $\(amount) - Balance: \(balance)")
}
}
Actors provide mutual exclusion automatically. No locks to manage, no ordering to remember. The compiler ensures thread safety.
Spotting Lock Ordering Issues
In code review, watch for:
- Methods that acquire multiple locks
- Nested lock acquisitions (lock inside a method that's called while another lock is held)
- Recursive calls while holding a lock
- Callbacks that might acquire locks
The Rule
One lock at a time. If you need multiple locks, acquire them in the same order everywhere. Or better: use actors and let Swift handle it.