AnyCancellable.store(in:) thread safety
31 Jan 2021 ⇐ Notes archive(This is an entry in my technical notebook. There will likely be typos, mistakes, or wider logical leaps — the intent here is to “let others look over my shoulder while I figure things out.”)
Thready safety has been a weak point for me (my university’s OS class also being taught in pre-1.x
Rust didn’t help much, either hah); but, I’m slowly working on it.
Kyle Bashour’s reply to Curtis Herbert’s (Twitter) thread on the thread safety of AnyCancellable.store(in:)
(or analogously, for the RangeReplaceableCollection
overload) reminded me of a Combine gotcha. Swift’s Standard Library data structures aren’t thread-safe out of the box — which, in turn means we need to be extra careful when storing cancellation tokens across threads. Let’s tee up an example to see why.
If fetchCount
is called across multiple threads, .store(in:)
will concurrently modify cancellables
, possibly leading to race conditions. There’s even a related issue about this over on OpenCombine’s repository. So, we’ll need to lock around the store
call and while we could do the usual NSLocking.lock
and .unlock
dance, I looked around to see if we could do better. And I found a small helper over in TCA and in RxSwift.
extension NSRecursiveLock {
@inlinable @discardableResult
func sync<Value>(_ work: () -> Value) -> Value {
lock()
defer { unlock() }
return work()
}
}
We can then lean on this extension in our earlier example.
Prior art for this helper seems to be DispatchQueue
’s sync(execute:)
method. It might be tempting to further roll this logic up into an @Atomic
property wrapper, yet it unfortunately won’t help when fencing off collection types — like Set
in our case — because each thread would operate on its own copy of the data structure. Donny Wals walks through this in detail in a post on the topic.