Greetings, traveler!
Concurrency allows multiple tasks to be performed in parallel without affecting the final result. There are situations when data can be accessed by multiple entities from various threads simultaneously. Swift provides a lot of tools for handling such situations. As an example, you can check out my Swift Concurrency series.
But sometimes, we just want to make some of our properties atomic, which means there won’t be any issue if it is available from different threads and parts of the app. This is very relevant for Singletons.
Note
You can read more about the Singleton design pattern, find out why so many developers say it is an antipattern, and learn how to fix all these issues, including thread safety problems.
There is a solution in iOS 18 — Apple’s Synchronization
framework. If you want to read more about it, you can do so here.
For systems prior to iOS 18, Apple also offers another solution — Swift Atomics. However, you can find this warning in its documentation:
Atomic values are fundamental to managing concurrency. However, they are far too low level to be used lightly. These things are full of traps. They are extremely difficult to use correctly — far trickier than, say, unsafe pointers.
The best way to deal with atomics is to avoid directly using them. It’s always better to rely on higher-level constructs, whenever possible.
This package exists to support the few cases where the use of atomics is unavoidable — such as when implementing those high-level synchronization/concurrency constructs.
Swift itself does not offer a public API for marking properties as atomic ones, but we can make one ourselves — something simple and easy to use. Looking ahead, I would like to say that this approach may not be without its challenges. Hidden details could lead to unexpected outcomes. However, we will deal with them as they arise. As a bonus, we will try to solve these problems using the experimental Swift API.
Property Wrapper
We will create a property wrapper, one of my favorite features in Swift. If you want to read more about it, check out this article.
Create a property wrapper called Atomic. This property wrapper is a generic struct that can apply different values. It has a wrappedValue
and a storage
to store the desired value.
@propertyWrapper
struct Atomic<Value> {
var wrappedValue: Value {
get {
storage
}
set {
storage = newValue
}
}
private var storage: Value
init(wrappedValue: Value) {
self.storage = wrappedValue
}
}
The trick is that we will use the GCD to handle concurrent access to this property. So, let’s create a queue to get and set values for the storage synchronously.
@propertyWrapper
struct Atomic<Value> {
private let queue = DispatchQueue(label: "livsycode.atomic.queue")
var wrappedValue: Value {
get {
queue.sync {
storage
}
}
set {
queue.sync {
storage = newValue
}
}
}
private var storage: Value
init(wrappedValue: Value) {
self.storage = wrappedValue
}
}
That’s it! Now, we can use it in an atomic way.
class Client {
@Atomic var integer = 1
func doSmth() {
integer = 1
}
}
Problem #1: The get
and set
combination
However, this will work only if we use set
or get
separately. Using both of them at the same time will have no atomic effect.
class Client {
@Atomic var integer = 1
func doSmth() {
integer += 1
}
}
Problem #2: Collections
Since Swift collections are value types, using this property wrapper to store them can cause unexpected behavior. Check out this example:
class Client {
@Atomic var storage = [Int]()
}
let client = Client()
let array = [1,2,3,4,5,6,7,8,9,0]
let group = DispatchGroup()
array.forEach { element in
group.enter()
DispatchQueue.global().async {
client.storage.append(element)
group.leave()
}
}
group.notify(queue: .main) {
print(client.storage)
}
We can expect an output like this:
[1,2,3,4,5,6,7,8,9,0]
But all we will get is:
[1, 0]
Since Array
is a value type each time we call the append
method, the property wrapper gives us a copy of this array. After modifying this copy, it assigns its value to the wrappedValue
property. So, multiple copies of an empty array will be created first, and then all of these copies will be modified. The last one will be the new wrappedValue
.
Modify method
First, let’s change struct
to class
. Second, let’s add a projectedValue
property to project the wrapped value into the another value. Then, add the modify
method:
@propertyWrapper
class Atomic<Value> {
private let queue = DispatchQueue(label: "livsycode.atomic.queue")
var wrappedValue: Value {
get {
queue.sync {
storage
}
}
set {
queue.sync {
storage = newValue
}
}
}
var projectedValue: Atomic<Value> { self }
private var storage: Value
init(wrappedValue: Value) {
self.storage = wrappedValue
}
func modify(_ modifier: (inout Value) -> Void) {
queue.sync { modifier(&storage) }
}
}
This method allows safe value modification using a closure containing a inout
parameter. Since this parameter is inout
, we can modify the external value inside our queue.
We can use this method with the projected value, using the $
symbol before the property name.
Let’s try to solve the first problem.
class Client {
@Atomic var integer = 0
}
let client = Client()
client.$integer.modify { $0 += 1 }
Everything is ok now. So, let’s check out the Collection
issue.
class Client {
@Atomic var storage = [Int]()
}
let client = Client()
let array = [1,2,3,4,5,6,7,8,9,0]
let group = DispatchGroup()
array.forEach { element in
group.enter()
DispatchQueue.global().async {
client.$storage.modify { $0.append(element) }
group.leave()
}
}
group.notify(queue: .main) {
print(client.storage)
}
[1, 2, 4, 3, 5, 6, 7, 8, 9, 0]
Everything works as expected. However, this approach’s API seems ambiguous, so let’s try another one.
Wrapper class
We can create a separate class to hide properties. This class will be used as a wrapper.
We will create four methods:
- The
read
method. - The
read
method with closure. - The
modify
method with a new value. - The
modify
method with closure.
Every one of these methods will be performed inside our queue.
final class Atomic<Value> {
private var value: Value
private let queue = DispatchQueue(label: "livsycode.atomic.queue")
init(_ value: Value) {
self.value = value
}
func read<T>(_ keyPath: KeyPath<Value, T>) -> T {
queue.sync {
value[keyPath: keyPath]
}
}
func read<T>(_ reading: (Value) -> T) -> T {
queue.sync {
reading(value)
}
}
func modify(_ newValue: Value) {
queue.sync {
value = newValue
}
}
func modify(_ modifier: (inout Value) -> Void) {
queue.sync {
modifier(&value)
}
}
}
Let’s test it out.
let atomicNumber = Atomic(0)
atomicNumber.modify(10)
print(atomicNumber.read(\.self))
10
Note
As you can see, the read
method uses a keypath. I invite you to read more about Swift keypaths and KVC.
Cool. And what about collections?
let array = Atomic([1,2,3,4,5])
print(array.read { $0.contains(2) })
print(array.read(\.count))
array.modify { $0.append(6) }
print(array.read(\.self))
true
5
[1, 2, 3, 4, 5, 6]
And let’s try to perform our DispatchQueue
test again:
class Client {
var storage: Atomic<[Int]> = .init([])
}
let client = Client()
let array = [1,2,3,4,5,6,7,8,9,0]
let group = DispatchGroup()
array.forEach { element in
group.enter()
DispatchQueue.global().async {
client.storage.modify { $0.append(element) }
group.leave()
}
}
group.notify(queue: .main) {
print(client.storage.read(\.self))
}
[1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
Everything works fine. This approach looks safer, but its API is a bit complicated. We can create another solution to deal with collections to provide a more familiar interface.
Collection wrapper class
Since we will use a separate class as a wrapper, entities won’t get copies of the collection but will modify only this class, and only one instance of this class will modify the collection it holds.
Here, we will use the barrier
flag when dealing with setting values. With this flag, all other blocks submitted before the barrier will finish, and only then will the barrier block execute. And blocks submitted after the barrier won’t start until the barrier is executed.
class AtomicArray<Element> {
private let queue = DispatchQueue(
label: "livsycode.atomic-array.queue",
qos: .default,
attributes: .concurrent
)
private var storage: [Element] = []
subscript(index: Int) -> Element {
get {
queue.sync {
storage[index]
}
}
set {
queue.async(flags: .barrier) {
self.storage[index] = newValue
}
}
}
func all() -> [Element] {
queue.sync {
storage
}
}
func append(_ newElement: Element) {
queue.async(flags: .barrier) {
self.storage.append(newElement)
}
}
func removeAll() {
queue.async(flags: .barrier) {
self.storage.removeAll()
}
}
func forEach(
_ body: (Element) throws -> Void
) rethrows {
try queue.sync {
try storage.forEach(body)
}
}
}
Now, let’s use it:
class Client {
var storage: AtomicArray<Int> = .init()
}
let client = Client()
let array = [1,2,3,4,5,6,7,8,9,0]
let group = DispatchGroup()
array.forEach { element in
group.enter()
DispatchQueue.global().async {
client.storage.append(element)
group.leave()
}
}
group.notify(queue: .main) {
print(client.storage.all())
}
[1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
Nice!
You can use the same approach with different collections. For example, you can create a Dictionary
wrapper:
class AtomicDictionary<Key, Value> where Key: Hashable {
private let queue = DispatchQueue(
label: "livsycode.atomic-dictionary.queue",
qos: .default,
attributes: .concurrent
)
private var storage: [Key: Value] = [:]
subscript(key: Key) -> Value? {
get {
queue.sync {
storage[key]
}
}
set {
queue.async(flags: .barrier) {
self.storage[key] = newValue
}
}
}
subscript(key: Key, default value: Value) -> Value {
get {
queue.sync {
storage[key] ?? value
}
}
set {
queue.async(flags: .barrier) {
self.storage[key] = newValue
}
}
}
}
Bonus: Yielding accessors
Warning: This approach can’t be counted as stable.
Let’s try another way to perform atomic operations with the work-in-progress _read
and _modify
yielding accessors. As you can see, these accessor’s names start with the underscore, so it means that using them can be risky. You can read more about it here and here.
Note
By the way, here you can read about another work-in-progress feature — the underscore attributes.
With the _modify
accessor, we will avoid copying values. The yield
keyword will transfer control back to the caller with a reference to storage
property.
@propertyWrapper
struct Atomic<Value> {
var wrappedValue: Value {
get {
queue.sync { storage }
}
_modify {
lock.lock()
var temp: Value = storage
defer {
storage = temp
lock.unlock()
}
yield &temp
}
}
private let lock = NSLock()
private let queue = DispatchQueue(label: "livsycode.atomic.queue")
private var storage: Value
init(wrappedValue: Value) {
self.storage = wrappedValue
}
}
Let’s test it out:
class Client {
@Atomic var storage = [Int]()
}
let client = Client()
let array = [1,2,3,4,5,6,7,8,9,0]
let group = DispatchGroup()
array.forEach { element in
group.enter()
DispatchQueue.global().async {
client.storage.append(element)
group.leave()
}
}
group.notify(queue: .main) {
print(client.storage)
}
[1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
Good! However, we should remember that this approach is not currently recommended for use in production code.
Conclusion
These techniques help us guarantee that multithread operations with properties will produce the expected result. However, some hidden problems of this approach can make life more challenging.
If you enjoyed this article, please feel free to follow me on my social media: