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:
- Is it "safe"? (in that the code should not crash or otherwise terminate unexpectedly).
- 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!