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.