AnyCancellable.store(in:) thread safety

⇐ Notes archive

(This is an entry in my technical diary. 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.

(Gist permalink.)

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.

(Gist permalink.)

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.