The Law
Atomics are hard
Swift 5 turns on exclusivity checking by default. This has some interesting interactions with atomics, especially when running under the Thread Sanitizer (TSAN). If you've ever seen a TSAN report on some simple Swift code that looks obviously correct then you're probably running into this issue:
// Incorrect! Do not use this!
final class UnfairLock {
private var _lock = os_unfair_lock()
func locked<ReturnValue>(_ f: () throws -> ReturnValue) rethrows -> ReturnValue {
os_unfair_lock_lock(&_lock)
defer { os_unfair_lock_unlock(&_lock) }
return try f()
}
func assertOwned() {
os_unfair_lock_assert_owner(&_lock)
}
func assertNotOwned() {
os_unfair_lock_assert_not_owner(&_lock)
}
}
The problem lies in the Law Of Exclusivity. The ampersand operator in Swift is not the same as C's address of operator. It is the inout operator. Bridging ends up turning the inout os_unfair_lock
directly into the address of _lock
's storage but that isn't always the case.
Think about some of the awesome things Swift properties can do:
- Passing a property to a C function
inout
will firewillSet
anddidSet
observers on the property - Passing a property inout via a protocol works regardless of the underlying types
Here is a somewhat contrived scenario:
protocol MysteryBox {
var theBox: Int { get set }
}
struct SimpleStruct: MysteryBox {
var theBox: Int = 0
}
struct ComputedStruct: MysteryBox {
private var _realBox: Int = 0
var theBox: Int {
get { return _realBox }
set {
// muahahaha
_realBox = newValue + 1
}
}
}
class HeapBox: MysteryBox {
var theBox: Int = 0 {
didSet { print("Set!") }
}
}
func put<I: SignedInteger>(_ box: inout I, value: I) {
box = value
}
Now observe #2 in action:
var simple = SimpleStruct()
var computed = ComputedStruct()
let heap = HeapBox()
put(&simple.theBox, value: 42)
put(&computed.theBox, value: 42)
put(&heap.theBox, value: 42) //prints 'Set!'
How can &computed.theBox
be handing out an address? The getter and setter can run arbitrary code! How does didSet
fire on the HeapBox
?
&
turns out to be quite the magical operator. Under the covers Swift will materialize a temporary value, execute the put
function, then do a write-back to the actual property setter (or call any observers).
So if we go back to the original problem: TSAN is trying to be helpful by checking for simultaneous inout
operations on the property, even if we never perform an actual memory access. Simply accessing the property with &
triggers the race. What's more it is technically Swift's right to crash with an exclusivity error, though it doesn't happen to do so as of this writing.
See the Law of Exclusivity document linked earlier for an explanation of why Swift would work this way and what this will enable in the future (the short and inaccurate tl;dr: exclusivity enables significant perf optimizations and makes borrowing & move-only types possible).
The takeway is this: Under the Swift memory model the UnsafeLock
shown above is illegal. The correct strategy is to allocate storage for the lock yourself:
final class UnfairLock {
private var _lock: UnsafeMutablePointer<os_unfair_lock>
init() {
_lock = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1)
_lock.initialize(to: os_unfair_lock())
}
deinit {
_lock.deallocate()
}
func locked<ReturnValue>(_ f: () throws -> ReturnValue) rethrows -> ReturnValue {
os_unfair_lock_lock(_lock)
defer { os_unfair_lock_unlock(_lock) }
return try f()
}
func assertOwned() {
os_unfair_lock_assert_owner(_lock)
}
func assertNotOwned() {
os_unfair_lock_assert_not_owner(_lock)
}
}
This blog represents my own personal opinion and is not endorsed by my employer.