I ran into a memory issue that I don't understand why this could happen. For me, It seems like ARC doesn't guarantee thread-safety.
Let see the code below
@propertyWrapper
public struct AtomicCollection<T> {
private var value: [T]
private var lock = NSLock()
public var wrappedValue: [T] {
set {
lock.lock()
defer { lock.unlock() }
value = newValue
}
get {
lock.lock()
defer { lock.unlock() }
return value
}
}
public init(wrappedValue: [T]) {
self.value = wrappedValue
}
}
final class CollectionTest: XCTestCase {
func testExample() throws {
let rounds = 10000
let exp = expectation(description: "test")
exp.expectedFulfillmentCount = rounds
@AtomicCollection var array: [Int] = []
for i in 0..<rounds {
DispatchQueue.global().async {
array.append(i)
exp.fulfill()
}
}
wait(for: [exp])
}
}
It will crash for various reasons (see screenshots below)
I know that the test doesn't reflect typical application usage. My app is quite different from traditional app so the code above is just the simplest form for proof of the issue.
One more thing to mention here is that array.count
won't be equal to 10,000 as expected (probably because of copy-on-write snapshot)
So my questions are
- Is this a bug/undefined behavior/expected behavior of Swift/Obj-c ARC?
- Why this could happen?
- Any solutions suggest?
- How do you usually deal with thread-safe collection (array, dict, set)?
It would be interesting to see the implementation of append.
I suspect that append(…)
will normally use the _modify
accessor but that can’t happen in this case because of the property wrapper. Swift Evolution is starting to look at how to standardise this stuff; see [Pitch] Modify and read accessors. However, that’s not really germane to this issue.
It will crash for various reasons.
That’s because you’re not following Swift’s concurrency rules. I put your code into a small test project and compiled it with Xcode 16.1 and the compiler tells you what the problem is:
array.append(i)
// ^ Mutation of captured var 'array' in concurrently-executing code
Your code isn’t legal because it doesn’t follow the “Law of Exclusivity”. I have a link to the Swift Evolution proposal that explains that in my Swift Concurrency Proposal Index post.
Taking a step back, using a property wrapper for locking is most definitely an anti-pattern. It’s better — in that it’s easier, more efficient, and actually works (-: — to use a payload-carrying lock for that. If you can rely on the latest OS releases, the lock of choice is Mutex
. If you can’t, use OSAllocatedUnfairLock
. If you have to deploy way back, you can write your own lock that has similar semantics.
For example, this runs without crashing:
import Foundation
import os
func testExample() {
let rounds = 10000
let array = OSAllocatedUnfairLock<[Int]>(initialState: [])
for i in 0..<rounds {
DispatchQueue.global().async {
array.withLock { a in
a.append(i)
}
}
}
dispatchMain()
}
testExample()
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"