[iOS] Concurrency Swift 5.5+

aymen abassia
Bforbank Tech
Published in
7 min readJan 19, 2024

When working with concurrency, the first thing to keep in mind is that you need to be aware of the potential for race conditions or deadlocks. A race condition can occur when two or more threads are trying to access the same resource at the same time, which can lead to unpredictable results. In order to avoid these situations, you need to make sure that your code is thread-safe. This means that it needs to be able to handle being accessed by multiple threads simultaneously without causing any problems.

To audit and detect threading issues in your Swift and C language written code there is an LLVM based tool called The Thread Sanitizer. It was first introduced in Xcode 8 and can be a great tool to find less visible bugs in your code, like data races.

Activate Thread Sanitizer

In the following piece of code, the String property name was accessed by two different threads :

The background thread is writing to the name, and we are trying to print it in the same time. The behavior is unpredictable as it depends on whether the print statement or the write is executed first. This is an example of a Data Race.

How to solve a Data Race ?

The traditional way to write concurrent algorithms is NSOperation. NSOperation is an abstract class that offers a thread-safe structure for modeling state, priority, dependencies, and management. Its design consists in subdivide a concurrent algorithm into individual long-running, asynchronous tasks. Each task would be defined in its own subclass of NSOperation and instances of those classes would be combined via an objective API to create a partial order of tasks at runtime.

In 2014 Apple introduced Grand Central Dispatch (GCD). It abstracts threads away from the developer, who will not need to consider so many details. It gives an abstraction layer and decides which thread should be used to execute any given task.

Taking the above example, we could write a solution as follows using dispatch queues :

private let lockQueue = DispatchQueue(label: "name.lock.queue")
private var name: String = ""

func updateName() {
DispatchQueue.global().async {
self.lockQueue.async {
self.name.append("BForBank")
}
}

// Executed on the Main Thread
lockQueue.async {
// Executed on the lock queue
print(self.name)
}
}

New Way to solve a Data Race

Swift 5.5 has introduced some new features to handle concurrency. A whole new way was introduced to write concurrent programs and a new framework was born it’s called the concurrency framework. It revolves around a new concept called structured concurrency.

Async await in Swift

Completion callbacks were common in Swift to return from an asynchronous task. The above method would have been written as followed :

func fetchImages(completion: (Result<[UIImage], Error>) -> Void) {
// .. perform data request
}

Defining a method using a completion closure is still possible in Swift today, but it has a few downsides :

  • The completion block need to be closed in each possible method exit by calling completion closure. Otherwise will possibly result in an app waiting for a result endlessly.
  • Closures are harder to read. It’s not as easy to reason about the order of execution as compared to how easy it is with structured concurrency.
  • Retain cycles need to be avoided using weak references.

Async methods replace the often seen closure completion callbacks. With the Async method the example above will be :

func fetchImages() async throws -> [UIImage] {
// .. perform data request
}

And the call to the method will be :

do {
let images = try await fetchImages()
print("Fetched \(images.count) images.")
} catch {
print("Fetching images failed with error \(error)")
}

This example is performing an asynchronous task. Using the await keyword, we tell our program to await a result from the fetchImages method and only continue after a result arrived. This could either be a collection of images or an error if anything went wrong while fetching the images.

Structured concurrency with async-await method calls makes it easier to reason about the order of execution. Methods are linearly executed without going back and forth like you would do with closures.

Actors in Swift

Proposal SE-0306 introduced Actors and explain which problem they solve : Data Races.

Actors are like other Swift types : they can have initializers, methods, properties, and subscripts. Unlike structs, an actor requires defining initializers. It is important to notice that Actors are reference types.

Actors have an important difference compared to classes : they do not support inheritance.

Actors prevent data races by creating synchronized access to its isolated data.

To handle data races, before swift Actors, we had to either change our design or use DispatchQueue with locks to avoid multiple access to the same resources. Here is a sample class where we are handling the data race with barrier lock.

class DataRacesExample {

private let lock = DispatchQueue(label: "com.BforBank.DataRaces")
private let counterName = "DataRacesExample"
private var _counter = 0
var counter: Int {
lock.sync {
_counter
}
}

func updateCounter(by value: Int) {
lock.sync(flags: .barrier) {
_counter = _counter + value
}
}
}

In the example above we have a queue that has a barrier flag that stops reading while it’s being written by another thread. And we have a private property also in order to synchronise the update or read.

Actors in Swift is making our life easier from Swift 5.5 :

actor SampleActor {

let counterName = "SampleActor"
var counter = 0

func updateCounter(by value: Int) {
counter += 1
}
}

Quite a number of codes are simplified here :

  • No dispatch queue with locks : makes us write a clean code and understand it easily.
  • Behind the scene, Swift Actor forces the compiler for synchronised access by having optimised synchronised process.
  • Swift Actors prevents us from not introducing the data race unknowingly.

Accessing data from Actors :

If we try to access the actor as we normally do, the compiler will throw an error as below :

Because we will not be sure when the access will be granted to read/write. Hence swift comes up with new addition async. So, we can use await and async, it will serialise our access to that data. This error will come for computed properties as well, but wont come for constants as data race will not happen for constant properties.

Isolated access vs nonisolated access :

There are two types of access in swift Actors : isolated and nonisolated access. By default, all access in actor is isolated to prevent the data races. we may come across a use case where we are sure that it wont have data race. Then it does not make sense to use async/await over there. In such cases, we can mark the method as nonisolated.

nonisolated func updateCounter(by value: Int) {
counter += 1
}

Global Actors

Imagine that we are running on a case where data that needs to be isolated is scattered across a program. How we can bring all of that code into a single Actor instance ? Actors are fantastic for isolating instance data, providing a form of reference type that can be used in concurrent programs without introducing data races but in the same instance.

That’s exactly where we need global actors : A global actor is a type that has the @globalActor attribute and contains a static property named shared that provides a shared instance of an actor.

@globalActor actor AccountActor {
static var shared = AccountActor()
}

Global actors implicitly conform to the GlobalActor protocol, which describes the shared requirement. The conformance of a @globalActor type to the GlobalActor protocol must occur in the same source file as the type definition, and the conformance itself cannot be conditional.

Defining MainActor

Its a globally unique actor who performs its tasks on the main thread. You can use it for properties, methods, instances, and closures to perform tasks on the main thread.

Imagine when we need to fetch the account bank details, we do network request and when the response came we switch to the main thread, we used to do this :

func fetchAccountDetails() async {
// Here we do the normal Netwrok Request fo fetch the Account details

DispatchQueue.main.async {
// Here we switch to the main thread to update the Account details.
}
}

So now after having @MainActor we don’t need to use DispatchQueue anymore we can just label the function to @MainActor and it will execute the logic on the main thread like this :

@MainActor
func fetchAccountDetails() {
// Here we do the normal Netwrok Request fo fetch the Account Details
// After the request is done, we dont need to switch to Main thread anymore as @MainActor will take care for us.
}

But how can we use this GlobalActor along with MainActor

Imagine that you need to make everything in the class be executed on the main thread but in the same time there is a part of this class needs to be executed on a different thread, here comes the usage of global actor.

@MainActor
final class AccountViewModel {
func updateAccountDetails() {
print("Update the Account Details done on MainThread: \(Thread.isMainThread)")
print("Update the Account Details done on Thread: \(Thread.current)")
}

@AccountActor
func fetchAccountDetails() async {
print("Fetch the Account Details done on MainThread: \(Thread.isMainThread)")
print("Fetch the Account Details done on Thread: \(Thread.current)")
await updateAccountDetails()
}
}

@globalActor actor AccountActor {
static var shared = AccountActor()
}

The function fetchAccountDetails is labeled as @AccountActor which we did create earlier, then the fetch itself will be on the global actor like a different thread but the update itself will be on the main actor like main thread.

Fetch the Account Details done on MainThread: falseFetch the Account Details done on Thread: <NSThread: 0x700000c42950>{number = 12, name = (null)}

Update the Account Details done on MainThread: true
Update the Account Details done on Thread: <_NSMainThread: 0x700000c390c0>{number = 1, name = main}

--

--