Skip to content

Thread Safety with Locks (Swift)

A SwiftUI guide for coding agents. Also covers osallocatedunfairlock, nsrecursivelock, data race crash ios, withlock, protect shared mutable state, concurrent access class.

A class with mutable state shared across threads invites data races (crashes) and race conditions (wrong results). Wrapping every read and write in a lock makes the type safe to share.

Proving the Race

A concurrent test exposes the problem: many threads calling send on a plain store either crash or lose increments.

func testThreadSafety() {
    let store = Store<State, Void>(state: .init()) { state, _ in
        var state = state
        state.value += 1
        return state
    }

    DispatchQueue.concurrentPerform(iterations: 1_000_000) { _ in
        store.send(())
    }

    XCTAssertEqual(store.value, 1_000_000)
}

OSAllocatedUnfairLock

OSAllocatedUnfairLock, from iOS 16, wraps the state itself. Every access goes through withLock, which locks, runs the closure, and releases.

@dynamicMemberLookup final class Store<State, Action> {
    private var state: OSAllocatedUnfairLock<State>
    private let reduce: (State, Action) -> State

    init(state: State, reduce: @escaping (State, Action) -> State) {
        self.state = .init(initialState: state)
        self.reduce = reduce
    }

    subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
        state.withLock { $0[keyPath: keyPath] }
    }

    func send(_ action: Action) {
        state.withLock { state in
            state = reduce(state, action)
        }
    }
}

The gotcha: OSAllocatedUnfairLock is fast but not recursive. Locking it twice from the same thread crashes, so never call a locked method from inside another locked closure.

NSRecursiveLock

When re-entrant locking is possible, use NSRecursiveLock. The same thread may lock repeatedly; the price is that it is slower than the unfair lock.

private let lock = NSRecursiveLock()

subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
    lock.withLock { state[keyPath: keyPath] }
}

func send(_ action: Action) {
    lock.withLock { state = reduce(state, action) }
}

Make shared classes thread safe when you write them, not after the first mysterious crash: this class of bug is easy to create and hard to reproduce.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-thread-safety-in-swift-with-locks" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems