Understanding Swift's value type thread safety

I have some gaps in my mental model regarding the thread safety Swift provides when dealing with value types. I've put together some example scenarios with results when running tests on iOS 14 simulator (Xcode 12, Swift 5.3).

For each scenario, I'm interested answering a few questions:

  1. Is it "safe"? (in that the code should not crash or otherwise terminate unexpectedly).
  2. Are any guarantees made by the types in each scenario in regard to concurrency that explain the differences in behaviour or is this essentially undefined behaviour?

Scenario A: Ints

    // No crash
    func testScenarioA() throws {
        var store: Int = 0
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            store = i
            _ = store
        }
    }

    // No crash
    func testScenarioA2() throws {
        var store: Int = 100
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { _ in
            store.negate()
            _ = store
        }
    }

Scenario A1 & A2 seem to be able to handle concurrent reading and writing to the var directly and also concurrent read/write when mutating the Int.

Scenario B: Strings

    // No crash
    func testScenarioB1() throws {
        var store: String = ""
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            store = "\(i)"
            _ = store
        }
    }
    
    // Crash
    // Thread 2: EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
    //    #0    0x00007fff2f42146d in _swift_release_dealloc ()
    //    #1    0x00007fff2f2b05d7 in _StringGuts.prepareForAppendInPlace(totalCount:otherUTF8Count:) ()
    //    #2    0x00007fff2f2b075a in _StringGuts.append(_:) ()
    //    #3    0x00007fff2f2479ab in _StringGuts.append(_:) ()
    //    #4    0x000000010aba7942 in closure #1 in ConcurrentReadingiOSTests.testScenarioB1() at /Users/rob/workspace/scratch/ConcurrentReadingiOS/ConcurrentReadingiOSTests/ConcurrentReadingiOSTests.swift:149
    //    #5    0x00007fff53a0f0c1 in partial apply for thunk for @callee_guaranteed (@unowned Int) -> () ()
    //    #6    0x00007fff53a0f0e4 in thunk for @escaping @callee_guaranteed (@unowned Int) -> () ()
    func testScenarioB2() throws {
        var store: String = ""
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            store.append("\(i)")
            _ = store
        }
    }
    
    // No crash
    func testScenarioB3() throws {
        var store: String = ""
        let queue = DispatchQueue(label: "my-writer-queue")
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            queue.sync {
                store.append("\(i)")
            }
            _ = store
        }
    }

In B1 we seem to be able to read/write to the var directly. B2 crashes when concurrently mutating and reading the value. B3 avoids the crash by synchronizing the mutation but leaving the read unsynchronized.

Scenario C: Dictionaries

    // Crash
    // Thread 5: signal SIGABRT
    //    #0    0x00007fff5df7333a in __pthread_kill ()
    //    #1    0x00007fff5dfa8e60 in pthread_kill ()
    //    #2    0x00007fff200fabd4 in abort ()
    //    #3    0x00007fff201685d9 in malloc_vreport ()
    //    #4    0x00007fff2016895c in malloc_zone_error ()
    //    #5    0x00007fff2f421470 in _swift_release_dealloc ()
    //    #6    0x000000010d8d250b in closure #1 in ConcurrentReadingiOSTests.testScenarioC1() at /Users/rob/workspace/scratch/ConcurrentReadingiOS/ConcurrentReadingiOSTests/ConcurrentReadingiOSTests.swift:71
    func testScenarioC1() throws {
        var store: [String: String] = [:]
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            store = ["aKey": "\(i)"]
            _ = store
        }
    }
    
    // Crash
    // Thread 10: "-[NSTaggedPointerString count]: unrecognized selector sent to instance 0x8000000000000000"
    //    #0    0x00007fff2043a116 in __exceptionPreprocess ()
    //    #1    0x00007fff20177f78 in objc_exception_throw ()
    //    #2    0x00007fff20448c6f in -[NSObject(NSObject) doesNotRecognizeSelector:] ()
    //    #3    0x00007fff2043e666 in ___forwarding___ ()
    //    #4    0x00007fff20440698 in __forwarding_prep_0___ ()
    //    #5    0x00007fff2f14423a in Dictionary.subscript.setter ()
    //    #6    0x0000000104237f06 in closure #1 in ConcurrentReadingiOSTests.testScenarioC1() at /Users/rob/workspace/scratch/ConcurrentReadingiOS/ConcurrentReadingiOSTests/ConcurrentReadingiOSTests.swift:182
    func testScenarioC2() throws {
        var store: [String: String] = [:]
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            store["aKey"] = "\(i)"
            _ = store["aKey"]
        }
    }
    
    // Crash
    // Thread 8: "-[NSTaggedPointerString objectForKey:]: unrecognized selector sent to instance 0x8000000000000000"
    //    #0    0x00007fff2043a116 in __exceptionPreprocess ()
    //    #1    0x00007fff20177f78 in objc_exception_throw ()
    //    #2    0x00007fff20448c6f in -[NSObject(NSObject) doesNotRecognizeSelector:] ()
    //    #3    0x00007fff2043e666 in ___forwarding___ ()
    //    #4    0x00007fff20440698 in __forwarding_prep_0___ ()
    //    #5    0x00007fff2f1acb00 in Dictionary._Variant.subscript.getter ()
    //    #6    0x00007fff2f409199 in Dictionary.subscript.getter ()
    //    #7    0x0000000107a7d01f in closure #1 in ConcurrentReadingiOSTests.testScenarioC2() at /Users/rob/workspace/scratch/ConcurrentReadingiOS/ConcurrentReadingiOSTests/ConcurrentReadingiOSTests.swift:201
    func testScenarioC3() throws {
        var store: [String: String] = [:]
        let queue = DispatchQueue(label: "my-writer-queue")
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            queue.sync {
                store["aKey"] = "\(i)"
            }
            _ = store["aKey"]
        }
    }
    
    // No crash
    func testScenarioC4() throws {
        var store: [String: String] = [:]
        let queue = DispatchQueue(label: "my-writer-queue")
        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            queue.sync {
                store["aKey"] = "\(i)"
            }
            queue.sync {
                _ = store["aKey"]
            }
        }
    }

C1 crashes when directly setting the var concurrently which is different than A1 and B1 and it's not clear to me why that behaviour would be different.

C2 crashes when attempting concurrent read/write when mutating. C3 continues to crash even when synchronizing the dictionary update while leaving the read unsynchronized (which is different from B3). C4 synchronizes on both the read and write and avoids crashing.

I was under the impression that value types were much safer to work with in a multi-threaded environment than they appear to be. An early Swift blog post emphasizes the point:

In Swift, Array, String, and Dictionary are all value types. They behave much like a simple int value in C, acting as a unique instance of that data. You don’t need to do anything special — such as making an explicit copy — to prevent other code from modifying that data behind your back. Importantly, you can safely pass copies of values across threads without synchronization.

I appreciate any help in understanding this more clearly!

4 Likes

Before digging into the rest of your post.

Swift provides no thread-safety whatsoever (it does not have any concept of threads). You're responsible for synchronizing accesses to objects, including standard library types yourself, especially with standard library types. AFAICT, violations result in undefined behaviours.

That said, there seems to be some exception, like how ARC are atomics (empirically, I couldn't find a formal guarantee on this).

2 Likes

None of these cases is safe. The integer examples only work by accident. You must have some sort of synchronization in place to prevent races reading or writing to normal properties. If you do your tests with Thread Sanitizer enabled, which will turn most races into reliable traps with details about the accesses that raced.

13 Likes

Also, just to directly address the bit of the blog post that you quoted:

In the examples you've provided, you're not working with different copies of the values in question—all the threads are attempting to modify the same location in memory concurrently.

1 Like

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.

6 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] = [:]
8 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