Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

OSAllocatedUnfairLock in Swift: Stable Lock Identity and Bounded Critical Sections

Use OSAllocatedUnfairLock for synchronous Swift state, understand copied-handle identity, and keep critical sections safe across threads and async boundaries.

OSAllocatedUnfairLock is a low-level synchronization primitive for Swift code that needs to protect a small piece of mutable state during a synchronous operation. Its important design property is not merely that it is an unfair lock: Apple allocates the underlying lock in stable storage and exposes it through a Swift value. That avoids a dangerous mismatch between Swift value semantics and APIs such as os_unfair_lock, whose lock storage must remain at a stable address while threads may be waiting on it.

This API is useful when an actor or serial queue would add unnecessary asynchronous or scheduling machinery around a short synchronous invariant. It is not a general replacement for actors, and it does not make a long-running operation safe to perform while holding a lock. Choose it only when the protected state and every access path can be kept small, synchronous, and explicit.

Why stable storage matters in Swift

Swift structures can be copied, moved, or optimized in ways that make the address of a stored value an unsuitable identity for a thread lock. Apple specifically warns against treating an os_unfair_lock stored directly in a Swift value as a stable lock address. A waiter must acquire and release the same lock object; moving or copying the storage while it is in use breaks that assumption.

OSAllocatedUnfairLock wraps an operating-system allocation whose identity remains stable independently of the wrapper’s value layout. Copying the wrapper does not create a second independent lock. The copies refer to the same underlying allocation and therefore coordinate on the same protected state. Treat a copied wrapper as another handle to the same synchronization domain, not as a way to clone a counter or create a second lock.

The generic form stores state inside the lock. The closure passed to withLock receives exclusive mutable access to that state, and the lock is released when the closure returns, including when it throws. Keeping the state inside the lock makes it harder to accidentally expose a mutable backing property outside the synchronization boundary:

import os

final class RequestCounter {
    private let count = OSAllocatedUnfairLock(initialState: 0)

    func recordRequest() {
        count.withLock { value in
            value += 1
        }
    }

    func snapshot() -> Int {
        count.withLock { value in
            value
        }
    }
}

OSAllocatedUnfairLock is non-recursive. Acquiring the same lock again on the same thread before releasing it triggers a runtime exception. Keep callbacks and helper calls inside a critical section from re-entering a path that tries to acquire that lock again.

The protected closure should be short and predictable. Do not perform network or disk I/O, wait on a semaphore, call arbitrary client code, or perform expensive parsing while holding it. Such work lengthens contention and can create lock-order cycles with other subsystems. Compute independent values before acquiring the lock, update the shared invariant, then return a snapshot that callers can process after the lock has been released.

The lock protects only accesses that use it

A lock is not attached to a property by declaration. It protects a value only if every read and write that participates in the invariant uses the same lock. If a mutable reference escapes, if one method reads state directly, or if another lock protects a related field in a different order, the design may still race or deadlock.

Prefer one state object for values that must change atomically together. For example, updating a request count and its last-update timestamp in the same closure gives readers a consistent pair. Protecting each field with a different lock would permit an observer to see a count from one update and a timestamp from another. Establish and document a lock order when two independently protected resources must be acquired together; better still, redesign the data so the operation needs one synchronization owner.

The lock is unfair: it does not promise FIFO scheduling among waiters. Do not use it as a queue, rate limiter, condition variable, or deadline mechanism. If callers need ordering, cancellation, back-pressure, or a result delivered later, use an abstraction that expresses those semantics rather than relying on lock acquisition order.

Optional acquisition is not a spin-loop API

withLockIfAvailable attempts to acquire the lock without waiting and returns no result when another thread currently owns it. This can be useful when a caller has a legitimate fallback, such as skipping a best-effort cache refresh. A nil result means the operation did not run; it does not mean the protected state is empty or invalid.

Do not turn this method into an unbounded busy loop. Repeatedly retrying consumes CPU while the holder is trying to make progress. If the operation must eventually happen, use withLock or move the work to an actor, queue, or other scheduler with an explicit waiting policy. If it is acceptable to skip the operation, make the fallback visible in metrics or diagnostics so contention does not silently hide lost work.

Never suspend across an acquired lock

The lock APIs are synchronous. Do not call await, block on a task, or otherwise suspend while a critical section is active. An asynchronous task may resume on a different thread, while low-level unfair locks have thread-affine ownership rules for acquisition and release. Even apart from thread affinity, suspension can hold the lock while unrelated tasks need the protected state, creating a deadlock or severe latency spike.

If a state transition includes asynchronous work, divide it into phases. Under the lock, validate the current state and record a token or generation number. Release the lock before awaiting. When the result returns, reacquire the lock and verify that the token is still current before committing the result. For state whose lifecycle naturally spans asynchronous operations, an actor usually provides a clearer isolation boundary:

actor ImageCatalog {
    private var generation = 0

    func refresh(using fetch: () async throws -> [String]) async throws {
        generation += 1
        let requestGeneration = generation
        let names = try await fetch()
        guard requestGeneration == generation else { return }
        // Commit the result while actor-isolated, after suspension.
        _ = names
    }
}

This example illustrates the isolation and generation check, not a complete cache policy. Real code should define whether overlapping refreshes cancel, supersede, or merge, and should return meaningful errors to its caller.

Availability and migration choices

Apple’s current availability metadata lists OSAllocatedUnfairLock as available on macOS 13.0+, iOS, iPadOS, Mac Catalyst, and tvOS 16.0+, visionOS 1.0+, and watchOS 9.0+. Recheck the SDK documentation for the deployment targets you ship. Code whose deployment target is earlier than a listed minimum must guard use with availability checks or provide a separately designed fallback. Do not make an older target compile by substituting a raw os_unfair_lock inside a movable Swift value. Use an established primitive whose storage and lifecycle meet that target’s contract, such as NSLock or a serial DispatchQueue, and measure before choosing a lower-level implementation.

Use the lock only for synchronous in-process state. It does not coordinate separate processes, persist data, make a multi-file update transactional, or protect state that another code path accesses without the same lock. A lock also does not make a non-Sendable object safe to pass between tasks merely because one field is protected.

Verification checklist

Exercise the invariant under concurrent access, not only in a single-threaded unit test. A focused stress test can submit many increments from DispatchQueue.concurrentPerform and assert the final value. Also test error paths that leave the protected state unchanged, copied-wrapper behavior if a wrapper can be copied in your design, and the fallback from withLockIfAvailable. Run Thread Sanitizer in a suitable test configuration, and test the oldest supported OS because availability and fallback paths are part of the implementation.

Before shipping, answer these questions: Is every access to the invariant inside the same lock? Is the closure short, synchronous, and free of untrusted callbacks? Can two locks be acquired in a different order? Does any async operation suspend while holding the lock? Is the deployment target new enough, or is there a safe fallback? If the answers are explicit, OSAllocatedUnfairLock can be a compact tool for a narrow synchronous critical section. If they are not, prefer a higher-level ownership model that makes the concurrency contract easier to review.

Related:

Sources:

Comments