Correct Atomic PropertyWrapper in Swift | iOS Dev

Igor Sorokin (srk1nn)
3 min readFeb 29, 2024

--

Swift Property Wrapper

Introduction

Sometimes it may be necessary to synchronize access to a shared resource from different threads. The easiest way to do this — is to use NSLock. I have seen developers love to wrap NSLock into a special property wrapper. But sometimes it’s misleading.

Problem

In many projects, I see the property wrapper with NSLock, that locks wrappedValue. So we can mark property @Atomic and use it as usual.

@propertyWrapper
final class Atomic<Value> {
private var value: Value
private var lock = NSLock()

var wrappedValue: Value {
get {
lock.lock()
defer { lock.unlock() }
return value
}
set {
lock.lock()
defer { lock.unlock() }
value = newValue
}
}

init(wrappedValue value: Value) {
self.value = value
}
}

This wrapper is very convenient, but sometimes it’s confusing. Some developers (who don’t know how wrapper works) may consider that property absolutely thread-safe and misuse it. This in turn will lead to inconsistency.

Let’s consider some dangerous examples.

// Example 1
@Atomic var counter = 0
counter += 1

// Example 2
@Atomic var point = CGPoint(x: 0, y: 0)
point.x
point.y

// Example 3
@Atomic var numbers = [1, 2, 3]
numbers[0]
numbers.append(1)
numbers.contains(4)

Only the getter and the setter to the Value in the Atomic class are thread-safe. Other actions with Value are not thread-safe.

In the first example, we use the += operator. It means call atomic getter, increment, and call atomic setter. It turns out += operation itself is not atomic. So this could lead to inconsistency, when two threads read the same value, increment it, and write it back.

In the second example, we access the point’s properties, which isn’t thread-safe. Again, when we write the point variable in code, we call PropertyWrapper’s atomic getter. Other further manipulations with this object are not thread-safe. So if we start reading and writing to x or y from different threads, it will lead to undefined behavior.

The same happens in the third example.

To understand this, let’s discuss how the compiler looks at this situation.

@Atomic var point: CGPoint = .zero
// compiler unwraps to
var _point: Atomic<CGPoint> = .init(wrappedValue: .zero)
var point: CGPoint {
get { _point.wrappedValue }
set { _point.wrappedValue = newValue }
}

Knowing that, it all becomes obvious. The line point.x becomes _point.wrappedValue.x. And all operations after _point.wrappedValue aren’t thread-safe.

How to deal with it

To avoid such mistakes, we can remove implicit getters and setters and make separate functions. Such an API will prevent mistakes and allow to hold NSLock until an operation is finished.

final class Atomic<Value> {
private var value: Value
private let lock = NSLock()

init(_ value: Value) {
self.value = value
}

func access<T>(_ keyPath: KeyPath<Value, T>) -> T {
lock.lock()
defer { lock.unlock() }
return value[keyPath: keyPath]
}

func access<T>(_ accessing: (Value) throws -> T) rethrows -> T {
lock.lock()
defer { lock.unlock() }
return try accessing(value)
}

func mutate(_ newValue: Value) {
value = newValue
}

func mutate(_ mutation: (inout Value) throws -> Void) rethrows {
lock.lock()
defer { lock.unlock() }
try mutation(&value)
}
}

// Plain usage
let counter: Atomic<Int> = .init(0)
let old = counter.access(\.self)
counter.mutate(1)

// Properties / Methods usage
let numbers: Atomic<[Int]> = .init([])
let hasFour = numbers.access { $0.contains(4) }
numbers.access(\.count)
numbers.mutate { $0.append(1) }

Thanks for reading! Hope you found it useful.

--

--

Igor Sorokin (srk1nn)
Igor Sorokin (srk1nn)

No responses yet