Following on from my previous article around thread safety, you might be tempted to take advantage of the property wrapper functionality introduced in Swift 5.1 and build an @Atomic
property wrapper, allowing you to quickly wrap a property and make it thread safe. Let me tell you why this isn't a good idea.
Perhaps your property wrapper would look like the following:
@propertyWrapper
public class Atomic<T> {
private let mutex = Mutex()
private var internalValue: T
public init(wrappedValue: T) {
self.internalValue = wrappedValue
}
public var wrappedValue: T {
get { mutex.with { return internalValue } }
set { mutex.with { internalValue = newValue } }
}
}
This effectively wraps all get/set operations on the protected internalValue
in mutex.with
, meaning we can safely modify whatever we wrap from any number of threads. Right?
@Atomic var values: [Int] = []
func appendValues() {
DispatchQueue.concurrentPerform(iterations: 100) { value in
values.append(value) // ๐ค
}
}
... not quite.
Actually, this is not safe, and it won't work as you'd expect. Why? Our @Atomic
property wrapper guards individual reads/writes to the protected property, however it doesn't guard it across the mutation. If we pick this apart and think about how this looks from a get/set standpoint, we can understand why.
func appendValues() {
DispatchQueue.concurrentPerform(iterations: 100) { value in
var array = values // Thread-safe get.
array.append(value)
values = array // Thread-safe set.
}
}
If multiple threads are each independently appending a value to their copy of the values
array, then when the thread sets its modified value back, it may overwrite some other mutations that another thread has performed. If you run the above code, you'll find that the final array typically has 20-25 elements, rather than the expected 100.
You could always augment your property wrapper with a function similar to .with
that we added to Mutex
.
/// Allows us to get the actual `Atomic` instance with the $
/// prefix.
public var projectedValue: Atomic<T> {
return self
}
/// Modifies the protected value using `body`.
public func with<R>(
_ body: (inout T) throws -> R
) rethrows -> R {
return try mutex.with { try body(&internalValue) }
}
Using with
on our wrapped value now allows us to protect the value across the entire mutation.
@Atomic var values: [Int] = []
func appendValues() {
DispatchQueue.concurrentPerform(iterations: 100) { value in
$values.with { $0.append(value) } // ๐
}
}
Now, everything, the get, the mutation, and the set back, are all protected in the same atomic "transaction". values
will now contain 100 elements, following this concurrent-append.
So, whilst using .with
is safe, in the vast majority of cases using the wrapped value directly (get/set) is not. Even with .with
, it's far too easy to treat a property wrapped with @Atomic
as "thread safe", and to accidentally use it in a way that will give you incorrect behaviour further down the line.
The get/set functionality provided by our wrapper is dangerous, in the general case. So it's probably not a good idea to use the wrapper at all.