Understanding Swift's value type thread safety

Thanks for clarifying. Is C4 also unsafe? I would have thought it would avoid any races to store (thread sanitizer doesn't seem to report anything for that case either but I must be missing something). Unless the race is with the call to queue.sync itself...

What is the recommended synchronization approach for reading/writing properties across threads? I realize there are many options here and the optimal choice is likely to be context specific but I'm curious to hear people's perspectives. Should Swift Atomics be considered (for supported types) or should developers generally be sticking with DispatchQueues?

Yes, I think the incorrect belief in my mental model was related to thinking that copy-on-write would somehow "shield" developers from these kinds of problems.

Sorry, I didn't see C4. That is fine, because you're using sync to ensure that the modifications happen in an ordered fashion. libdispatch is probably the easiest to use library from Swift today, but you can use any synchronization mechanism that fits your project.

4 Likes

Other than to default to Dispatch, if you're already using other technology, OperationQueue, EventLoop, you could also just use that.

Note that for C3, I believe that the idiomatic implementation for a reader-writer queue using GCD is to dispatch everything to a concurrent queue (so that multiple reads can happen concurrently) and pass the .barrier flag for writes (to ensure that writes always have exclusive access to the resource).

3 Likes

All of these scenarios have unordered conflicting accesses to a single variable (i.e. data races) and therefore have undefined behavior. Scenario A doesn't fail dynamically because Int is a trivial type. However, when reading from a variable like this, the compiler is not required to honor the possibility of a concurrent store, which can cause apparent miscompiles, so it's still not safe.

As other people have mentioned, Swift doesn't directly provide any low-level mechanisms for handling concurrency; you have to use OS facilities like mutexes and Dispatch. We're actively working on improving this; our approach will be centered around making data races statically impossible as much as possible.

28 Likes

This is very helpful. Thank you!

Thanks John. I noticed that A1 and A2 output slightly different messaging when using Thread Sanitizer.

A1: Data race in closure #1 (Swift.Int)
A2: Swift access race in closure #1 (Swift.Int)

Is there an important distinction to be made between "pure" data races vs races when using mutating methods (like A2s use of negate())? Or are they essentially equivalent (with different diagnostics)?

This will be quite amazing. Is the Ownership Manifesto still the current resource for learning more about this?

It's the same high-level problem. I assume the low-level diagnostic difference is related to an inout method being called vs. just simple stores.

We plan to use ownership some, but a more on-point design is forthcoming.

6 Likes

Rule of thumb:

  1. Never access (read or write) a var from more than one thread (or queue). Only your testScenarioC4() adheres to this.
  2. If using value types, you can safely use lets from more than one thread/queue. You can also make new copies, even if they use copy-on-write internally (e.g. the collection types). If a value type has any reference type properties, see 3 below :slight_smile:
  3. If using reference types, it's only safe if all their properties are lets recursively (value type properties can of course be var). Or are otherwise made thread-safe.

For thread-safety often a serial dispatch queue or the unfair lock will be your best options. If speed matters a lot, then the unfair lock is probably what you want. If you have different priorities / QoS inside your program, you should be wary of using the concurrent queue with a barrier suggestion above to implement a R/W lock, as that doesn't support priority inversion avoidance.

5 Likes

And to further clarify, that's because closure captures silently confer reference semantics on instances that otherwise would have value semantics. When you mutate access store from inside the (escaping) closure, the compiler switches to storing it in a shared, dynamically-allocated box. Every copy of the closure shares the same box, thus the race condition.

This pitfall is one of my greatest concerns as we move toward introducing formal concurrency support to Swift, because it breaks the model that types with value semantics are immune to race conditions unless explicitly shared (e.g. in a global or via an enclosing class instance). If users had to explicitly mark escaping captured var bindings somehow, it would at least be easier to explain, e.g.

shared var store: [String: String] = [:]
7 Likes

Return of __block? :pleading_face:

Alternatively, static enforcement by the compiler should be able to catch that case fairly easily, without the new keyword. But we'll what the proposal entails.

var/let isn’t enough of a distinction, as the same memory exposed as a ‘let’ might be a ‘var’ elsewhere in the program. Even if you have a read-only handle to, say, some Array storage, there may still be another thread concurrently writing to it. COW does not provide atomicity.

All data which is (i) shared between threads and (ii) mutated between initialisation and use must always be synchronised. Even if it is a ‘let’.

1 Like

If you have a let to an array, I don't think anything else could write to it. If someone else has a var to it, that means you'd have two references to the same array, and writing through there would trigger COW, causing the write to go to a new copy without modifying the one pointed to by the let.

For that matter, even if everyone has a var, as long as each thread has a separate var everything should be fine
...not that that's really useful, since that would be no different from having a separate copy per thread, which probably is not what you wanted if you were using a var

Not if the var was a unique reference and started mutating it’s buffer before the second (‘let’) reference existed.

This would be a violation of the law of exclusivity, but AFAIK our current runtime enforcement doesn’t cover data races.

In that case, the let must have been made from the var (since it was a unique reference) in which case that's two threads accessing the same var, and the problem isn't with the resulting let

IMO reasoning about thread safety should only have to apply to objects that you didn't violate thread safety to create in the first place

3 Likes

The resulting ‘let’ is still reading from memory that is being mutated concurrently. It may read off the end of the Array, or read a torn value that is mid-update.

This all stems from the fact that the same memory was held as a ’var’ elsewhere in the program, which is the point I was making. Just because your thread holds a ‘let’, doesn’t mean it’s safe.

Yes but you created the let by violating thread safety, of course it's going to be horribly broken. I just think that if you violate thread safety to create an object, you can throw all thread-safety assumptions that you normally would make about that object out the window.

If you don't agree, then we can formulate the rule as "any let of a value type that was created without violating thread safety is safe to access from multiple threads at once"

1 Like

You don’t need to violate thread safety to create anything.

How else would you have a let sharing memory with a var that's being modified?

var under modification: Write
Creating let: Read
If both of those happen from separate threads, that's a thread safety violation, right?
If both of those happen from the same thread, that's a violation of the law of exclusivity that should be caught by the runtime enforcement

1 Like