The optimal method to ensure memory safety in a multi-threaded environment
The rise of parallelism in hardware has led to significant changes in software development. This has brought about the introduction of APIs for thread management and synchronization primitives in SDKs. Crafting a multi-threaded program that runs without glitches is a challenging endeavor. Among the most daunting challenges is ensuring different threads access memory without conflict.
In true parallel systems (like multi-core or multiprocessor setups), two threads might simultaneously read from and write to the same memory location, leading to a “data race.” Delving into the finer details (such as data size, alignment, processor caches, and atomics) might get complex, but using mutexes is the conventional method to prevent data races. When a mutex instance is in place, and its lock/unlock methods are appropriately called, it ensures the atomicity of data operations and exclusive access.
On Apple’s platforms, the os_unfair_lock is the most performance-efficient lock available.
In Swift, using it can be a bit more challenging compared to other locks. Prior to iOS 16, developers had to manage memory manually to prevent creating local instance copies.
import os
public final class UnfairLock {
public init() {
self.pointer = .allocate(capacity: 1)
self.pointer.initialize(to: os_unfair_lock())
}
deinit {
self.pointer.deinitialize(count: 1)
self.pointer.deallocate()
}
public func lock() {
os_unfair_lock_lock(self.pointer)
}
public func unlock() {
os_unfair_lock_unlock(self.pointer)
}
private let pointer: os_unfair_lock_t
}
Starting with iOS 16, you can utilize the newly introduced OSAllocatedUnfairLock type. Its possible implementation might be similar to the code example provided above:
let lock = OSAllocatedUnfairLock()
lock.lock()
// ...
lock.unlock()
To avoid repeatedly placing paired lock/unlock calls throughout the code and to prevent data races, many developers utilize a feature known as “property wrappers.” This acts as syntactic sugar by encapsulating the lock/unlock logic within itself:
@propertyWrapper
public final class ThreadSafe<T> {
public init(wrappedValue: T) {
self.value = wrappedValue
}
public var projectedValue: ThreadSafe<T> { self }
public var wrappedValue: T {
get {
self.lock.lock(); defer { self.lock.unlock() }
return self.value
}
set {
self.lock.lock(); defer { self.lock.unlock() }
self.value = newValue
}
}
private let lock = UnfairLock()
private var value: T
}
But there is one implicit problem in the wrappedValue implementation:
GET and SET are atomic, but GET+SET is not
What does it mean? For example, you have the following code:
@ThreadSafe
var x: UInt64 = 0
x += 1
This line of code, x += 1, essentially consists of a “get” operation followed by a “set” operation (x = x + 1). While each of these operations is atomic individually, there’s a potential issue: another thread might update the value between the “get” and “set” operations, leading to unexpected outcomes in the program logic. Experienced developers recognize this pitfall and often employ a helper method to mitigate this issue, as shown below:
public func write<V>(_ f: (inout T) -> V) -> V {
self.lock.lock(); defer { self.lock.unlock() }
return f(&self.value)
}
Which in the example with the counter leads to the following use of the property wrapper:
@ThreadSafe
var x: Int = 5
$x.write { $0 += 1 }
The solution works, but it doesn’t eliminate the human element. This is because both expressions x += 1 and $x.write { $0 += 1 } are considered valid by the compiler.
However, there’s no need to worry. Swift’s undocumented feature allows you to merge paired “get+set” calls into a single operation. This feature is actively used within the standard Swift library and is named _modify :
var wrappedValue: T {
get { return value }
_modify { yield &self.value }
}
_modify is an accessor (like get and set) with two very important advantages:
- Atomicity — combining “get+set” into one call
- Performance — working with data in place, without copying
You can read more about _modify and yield in the original pitch and this post.
_modify is a publicly accessible API that remains undocumented. However, based on my year and a half of experience using it in a project that serves millions of users, I’ve encountered no issues.
Happy Ending
Combining unfair lock with _modify, we achieve the most convenient and optimal method for ensuring memory safety in multi-threaded environment:
public var wrappedValue: T {
get {
self.lock.lock(); defer { self.lock.unlock() }
return self.value
}
_modify {
self.lock.lock(); defer { self.lock.unlock() }
yield &self.value
}
}
...
private let lock = UnfairLock()
The code from the article is provided as a production-ready Swift package in this repository. Enjoy!
Mastering Thread Safety in Swift With One Runtime Trick was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.