- Published on
- 7 min read Intermediate
> OSAllocatedUnfairLock: The Type-Safe Replacement for os_unfair_lock
For years the fastest way to synchronize shared state on Apple platforms was os_unfair_lock. It is a tiny C struct, a handful of instructions on the uncontended path, and about as close to the metal as you can get without dropping into atomics. It was also a minefield. You could copy the struct by accident, leak it by forgetting to unlock, or corrupt it by letting the pointer outlive its storage. Since iOS 16, Swift has a first-class wrapper called OSAllocatedUnfairLock that solves all three problems, and it should be what you reach for whenever you would have written raw os_unfair_lock code in the past.
What the old pattern actually looked like
A typical hand-rolled wrapper for os_unfair_lock looked something like this:
import os
final class Counter {
private let lock: UnsafeMutablePointer<os_unfair_lock_s>
private var _value = 0
init() {
lock = .allocate(capacity: 1)
lock.initialize(to: os_unfair_lock())
}
deinit {
lock.deinitialize(count: 1)
lock.deallocate()
}
func increment() {
os_unfair_lock_lock(lock)
_value += 1
os_unfair_lock_unlock(lock)
}
func value() -> Int {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return _value
}
}
Every line of that is a chance to make a mistake. If you store os_unfair_lock directly as a property on a struct, the struct can be copied and each copy gets its own lock, so synchronization silently stops working. If you store it as a property on a class and pass &self.lock to the C function, the address you hand over is only valid for the duration of that call. The compiler is free to move the storage, and os_unfair_lock requires a stable address for its entire lifetime. That is why the manual version uses UnsafeMutablePointer.allocate and paired cleanup in deinit. It works, but you are one typo away from a crash that only shows up under load.
The increment() method above also has the classic shape of the bug everyone eventually writes. Add an early return, a guard, or a throw between the lock and unlock, and suddenly one path leaves the lock held forever. defer fixes that, and the value() method uses it, but nothing forces you to remember.
OSAllocatedUnfairLock in one screen
The modern replacement fits in a few lines and shuts all three doors at once:
import os
final class Counter {
private let state = OSAllocatedUnfairLock(initialState: 0)
func increment() {
state.withLock { $0 += 1 }
}
func value() -> Int {
state.withLock { $0 }
}
}
OSAllocatedUnfairLock<State> stores the value alongside the lock in heap-allocated storage that the Swift object owns. You do not allocate, initialize, or deallocate anything yourself. The lock cannot be copied out into a new instance because it is a reference type with no public initializer that takes another lock. The pointer stability problem disappears because the storage is owned by a class, not inlined into a struct that might get memcpy'd.
The second door that closes is the unlock door. withLock takes a closure, acquires the lock, runs the closure, and releases the lock when the closure returns, whether it returns normally, throws, or hits an early return. You do not have a path that forgets to unlock because you do not write the unlock at all. The closure receives the protected state as an inout parameter, which is why $0 += 1 above mutates the counter in place.
The closure can throw and can return a value:
struct Cache {
private let storage = OSAllocatedUnfairLock<[String: Data]>(initialState: [:])
func fetch(_ key: String) -> Data? {
storage.withLock { $0[key] }
}
func store(_ value: Data, for key: String) {
storage.withLock { $0[key] = value }
}
}
Anything you would previously write between os_unfair_lock_lock and os_unfair_lock_unlock goes inside the closure, and the type of the closure's return is the type returned by withLock.
When there's no state to protect
Sometimes you just need mutual exclusion around an operation that has no natural "state" to hand to the closure. Maybe you are guarding a one-shot initializer, coordinating access to an external resource, or enforcing that only one thread runs a particular code path at a time. For that, use the Void form:
final class Uploader {
private let lock = OSAllocatedUnfairLock()
func flush() {
lock.withLock {
// do the work, nothing to mutate
}
}
}
The no-argument initializer gives you OSAllocatedUnfairLock<Void>(), and withLock accepts a closure with no parameters. You keep the "cannot forget to unlock" guarantee without paying for storage you are not going to use.
When to reach for it instead of an actor
Actors are the default answer for isolating state in Swift concurrency, and they should be. They compose with async, participate in the cooperative thread pool, and are checked by the compiler. OSAllocatedUnfairLock is the right tool for the narrower cases where actors cannot go:
- Synchronous call sites. A property getter, an
init, a C callback, or any non-async code cannot call an actor method without hopping throughTask { ... }, which defeats the point. - Hot paths where a hop to another executor is measurable. Actor calls are cheap, but they are not free, and in a tight loop that runs millions of times per second the difference shows up in Instruments.
- Delegate-style APIs from older frameworks that deliver callbacks on arbitrary threads and cannot be made async.
For a deeper comparison of the synchronization primitives available in Swift, see Resource Isolation in Swift Using Locks and Resource Isolation in Swift Using Actors.
The recursion footgun
One trap OSAllocatedUnfairLock does not remove, because it is not a property of the wrapper but of the underlying primitive, is that os_unfair_lock is not recursive. If a thread that already holds the lock tries to acquire it again, the behavior is undefined, and in practice you will usually deadlock. This can happen in ways that are not obvious from the call site. If the closure you pass to withLock calls out to code that, somewhere down the stack, calls back into the same lock, you are done. Keep the body of withLock small and self-contained, and do not call out to code you do not control while holding the lock.
Actors do not have this footgun, because reentrancy on an actor is handled by suspending at the await and letting the actor accept another message. That is a feature of the actor model, not something you can retrofit onto a lock. If you find yourself needing reentrancy, that is a signal to step back and consider whether an actor is the better fit for the code in question, or whether the design can be restructured so the recursive call does not need to cross the lock boundary.
A small but meaningful upgrade
OSAllocatedUnfairLock does not change what locks are good at, or what they are bad at. It just removes three of the most common ways to misuse the fastest lock on the platform. The wrapper manages the storage, the closure API manages the unlock, and the generic parameter puts the protected state in one place where it is obvious what the lock is guarding. If you are still maintaining hand-rolled wrappers around os_unfair_lock_s, this is a safe and essentially free refactor.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
withCheckedContinuation vs withUnsafeContinuation in Swift
Continuations bridge completion-handler APIs into async/await. The checked variant catches the two ways you can get it wrong, and the unsafe one trusts you completely.
Data Races Swift 6 Still Can't Catch
Swift 6's data-race safety is real, but it has blind spots. Here are the places the compiler can't see, and how to stop treating a clean build as proof your code is thread safe.
Async defer in Swift 6.4
SE-0493 finally lets you write defer { await cleanup() } in async functions, without spawning a detached task or threading cleanup logic through every return path.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.