Fair vs Unfair Locks: NSLock and os_unfair_lock

We've covered serial queues and actors for thread safety. But what about locks? iOS offers several, and they're not all the same—some guarantee fairness, others trade it for speed.
Follow along with the code: iOS-Practice on GitHub
The Lock Landscape
Beyond queues and actors, iOS provides several lock primitives:
| Lock | Fairness | API | Use Case |
|---|---|---|---|
| NSLock | Fair (FIFO) | Foundation | General purpose, Obj-C interop |
| os_unfair_lock | Unfair | C | Maximum performance |
| OSAllocatedUnfairLock | Unfair | Swift | Modern Swift (iOS 16+) |
Fair vs Unfair: What's the Difference?
Fair locks (NSLock) grant access in FIFO order. If thread A requests the lock before thread B, A gets it first. Predictable, no starvation.
Unfair locks (os_unfair_lock) let any waiting thread grab the lock. A thread that just released might immediately reacquire it, starving others.
NSLock: The Fair Option
final class FairContainer: @unchecked Sendable {
private var values: [Int] = []
private let lock = NSLock()
func append(_ value: Int) {
lock.lock()
defer { lock.unlock() }
values.append(value)
}
}
NSLock guarantees ordering. Under heavy contention, every thread eventually gets its turn. Tail latency is predictable.
os_unfair_lock: The Fast Option
final class UnfairContainer: @unchecked Sendable {
private var values: [Int] = []
private let lockPtr: UnsafeMutablePointer<os_unfair_lock>
init() {
lockPtr = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1)
lockPtr.initialize(to: os_unfair_lock())
}
deinit {
lockPtr.deinitialize(count: 1)
lockPtr.deallocate()
}
func append(_ value: Int) {
os_unfair_lock_lock(lockPtr)
defer { os_unfair_lock_unlock(lockPtr) }
values.append(value)
}
}
Notice the complexity: heap allocation, manual memory management, unsafe pointers. That's the price of low-level performance.
os_unfair_lock Pitfalls
Before using os_unfair_lock, understand these gotchas:
- Must allocate on heap - It's a value type, but can't be copied. Use a pointer.
- Cannot hold across await points - Locks are thread-bound; async/await can resume on different threads.
- No FIFO guarantee - Threads can starve under heavy contention.
- Tail latency spikes - Some requests may wait much longer than average.
- Manual memory management - You're responsible for allocation and deallocation.
OSAllocatedUnfairLock: The Modern Wrapper
iOS 16+ provides a safer API:
@available(iOS 16.0, *)
final class ModernContainer: @unchecked Sendable {
private let lock = OSAllocatedUnfairLock(initialState: [Int]())
func append(_ value: Int) {
lock.withLock { $0.append(value) }
}
func getAll() -> [Int] {
lock.withLock { $0 }
}
}
Same performance as os_unfair_lock, but:
- No manual memory management
- Scoped locking with
withLock - State is encapsulated with the lock
When Fairness Matters
Choose NSLock when:
- You care about worst-case latency, not just average
- User-facing operations that shouldn't block unpredictably
- Systems where starvation could cause visible issues
Choose os_unfair_lock / OSAllocatedUnfairLock when:
- You've measured NSLock as a bottleneck
- Operations are short and contention is low
- Average throughput matters more than tail latency
Interview Tip
"Fair locks guarantee ordering at the cost of some throughput. Unfair locks are faster on average but can starve threads under contention. I use NSLock by default—predictable tail latency matters for user experience. I only switch to unfair locks after measuring a real bottleneck."