My Swift code uses objc_sync_enter & objc_sync_exit methods to implement @synchronized primitive (available in Objective-C) in Swift. However, this answer claims it is an outdated as well as inappropriate way to implement synchronised access in Swift. While the reasons for the same are not provided, I would like to know modern ways of implementing critical sections in Swift where a number of variables are accessed in a block, but the same variables are written infrequently (such as when UI orientation or app settings change).
1 Answer
You asked:
I would like to know modern ways of implementing critical sections in Swift where a number of variables are accessed in a block
The modern way is to use actors. See The Swift Programming Guide: Concurrency: Actors:
actor TemperatureLogger {
let label: String
var measurements: [Int]
private(set) var max: Int
init(label: String, measurement: Int) {
self.label = label
self.measurements = [measurement]
self.max = measurement
}
...
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
Also see WWDC 2022 video Eliminate data races using Swift Concurrency or 2021’s Protect mutable state with Swift actors.
In codebases where you have not yet adopted Swift concurrency (e.g., async-await) you could use a lock or GCD serial queue or any of a variety of other synchronization mechanisms:
class TemperatureLogger: @unchecked Sendable { // `@unchecked Sendable` tells the compiler that we are doing our own synchronization; this is very useful when using strict concurrency checking and/or Swift 6
let label: String
private var _measurements: [Int] // private backing variable
var measurements: [Int] { lock.withLock { _measurements } } // synchronized computed var to return value
private var _max: Int // private backing variable
var max: Int { lock.withLock { _max } } // synchronized computed var to return value
private let lock = NSLock() // or mutex or OSAllocatedLock or …
init(label: String, measurement: Int) {
self.label = label
self._measurements = [measurement]
self._max = measurement
}
func update(with measurement: Int) {
lock.withLock {
_measurements.append(measurement)
if measurement > _max {
_max = measurement
}
}
}
}
Locks are a very performant synchronization mechanism. Serial dispatch queues are an alternative mechanism, too. Regardless of which synchronization mechanism you choose, make sure all interactions (both reads and writes) go through this synchronization mechanism. Specifically, do not expose the private backing variable, but rather only provide mechanisms (such as computed variable, update methods, etc.) that ensure the state is properly synchronized.
When considering synchronization mechanisms, you might stumble across the “reader-writer” pattern (in which one uses concurrent GCD queue, performing writes asynchronously with a barrier and performing reads synchronously without a barrier). While this is intuitively appealing, it actually offers only a modest performance improvement over a serial dispatch queue, is less performant than locks, and introduces all sorts of complicating factors, especially in thread-explosion scenarios.
See https://stackoverflow.com/a/73314198/1271826 and the links contained therein for a discussion of a variety of synchronization mechanisms.
You go on to say:
... where a number of variables are accessed in a block, but the same variables are written infrequently (such as when UI orientation or app settings change).
If it is simply for UI updates, a common technique is merely to dispatch these blocks to the main queue. This works because (a) all UI updates must happen on the main thread, anyway; and (b) the main queue is a serial queue.
In short, especially when for UI updates, dispatching related model and UI updates the main queue is a quick-and-dirty synchronization mechanism.
NSLock,NSRecursiveLock,os_unfair_lockto do locking in Swift? There are resources already here on SO that should be able to help you out. (e.g., you can implement locking withos_unfair_locklike so as an alternative)objc_sync_enter/objc_sync_exit, one main reason: it's really easy to get wrong, and lock on a struct, which isn't valid. See, e.g., stackoverflow.com/questions/70896707/…. (It's also much less performant than other locking mechanisms, but that's a concern in Obj-C too)NSLockuses pthreads; from the docs: "The NSLock class uses POSIX threads to implement its locking behavior".os_unfair_lockis implemented entirely differently, but shares the same general ideas. In general, though, I wouldn't be concerned about the specific implementation details in either case: just know that these are modern tools that are safe to use from Swift.NSLock...os_unfair_lock... in either case: just know that these are modern tools that are safe to use from Swift.” I know you know this, but one has be extremely careful withos_unfair_lockfrom Swift. This is why iOS 16 introducedOSAllocatedUnfairLock, to avoid the rigmarole required to useos_unfair_locksafely from Swift. Better to stick w actors or GCD, IMHO, unless performance is of paramount concern (which it generally isn’t).