Swift GCD part 1: Thread safe singletons

Preview

Singletons are entities, referenced to the same instance of a class from everywhere in your code. It doesn't matter if you like them or not, you will definitely meet them, so it's better to understand how they work.

Constructing and handling a set of data doesn't seem to be a big challenge at first glance. The problems appear when you try to optimise the user experience with background work and your app starts acting weird. 🤷🏻‍♂️ After decades of watching your display mostly with a blank face, you finally realize that your data isn't handled consistently by the manager because you're accessing it (running tasks on it) from multiple threads at the same time. So you really do have to deal with making your singletons thread safe.

This article series is dedicated to thread handling using Swift. In the first part below you will get a comprehensive insight into some main parts of Apple's GCD and constructing a thread safe singleton class, which can save your skin in a lot of cases. If you've already met singletons, feel free to skip the next section. Let's get started!

About singletons

Singleton is one of the most common design patterns. It solves problems such as dealing with instance creation, and making sure that there is always exactly one instance of the class which is accessible from all around the code, which saves memory and can act like a centralised manager. Most developers prefer real examples, so I've created you a quick tutorial which holds your hand while you get familiar with this magic.

The base of our example is a store, where you can buy or borrow some fancy customised computers, so you will need something that holds and manages the stock, like a StoreManager:

class StoreManager {  
  static let shared = StoreManager()
  private init() { }
}

The static shared property is accessible from everywhere but the class can be initialized just once, at the first access, and it will be handled by the class itself.

As I mentioned before, we want it to hold the stock, so we'll need an array that contains the currently available computers:

private var availableComputers: [Computer]  // also initialization  

On the other hand, we have to implement three methods that manage the incoming stock and the sold ones:

func add(_ computer: Computer) {  
  availableComputers.append(computer)
}

func getComputers() -> [Computer] {  
  return availableComputers.sorted { $0.constructionYear < $1.constructionYear }
}

func remove(_ computer: Computer) {  
  availableComputers.removeAll { $0.serial == computer.serial }
}

With this basic class our code is able to hold the stock in the special case when the store is only accessed by one thread at a time. But what would happen if somebody from another thread added a new computer while we're trying to get the available ones? To figure it out, we have to get a little deeper into GCD in general.

What is GCD?

According to Apple's definition, GCD (Grand Central Dispatch, aka. Dispatch) is able to:

Execute code concurrently on multicore hardware by submitting work to dispatch queues managed by the system.

In a sentence: GCD is a great abstraction above thread handling in iOS. Now let's clear up some concepts to understand the rest of our example solution.

Queue

In GCD's context a queue is basically a FIFO list with work items that need to be done by the CPU. Two types of queues are available, so you can choose according to the type of your tasks:

  • Serial: your work items will be executed one after another and exactly one at the time

  • Concurrent: the order of the task executions will be the same as you added them to the queue with a key difference:

    • In a single core device while one task is running, the OS may pause it, switch context, run another task and switch back to the first one. The final result will look like as if the tasks were done at the same time.
    • In a multi core device these tasks will be executed at the same time on different threads which makes the process faster. Using parallelism can be complicated, can cause a lot of trouble and also slow down your whole processing, so you have to be very careful with it. Luckily for you, I will explain this in a following part of this article series.

Here you can see an example for context switching in a concurrent single-core system:

Context switching in a concurrent single-core system

Synchronous vs. asynchronous task execution

You've probably read about async task in iOS, at least in a Stack Overflow answer, but even if you haven't, I got your back.

Asynchronous function calls guarantee that the called function returns immediately after the call, and the runtime won't wait until it completes its task, instead continues executing the rest of the code that comes after the call to the function. As a result, the order of such tasks finishing is not deterministic.

Asynchronous execution

The opposite happens when you call a function in the regular, synchronous way: the runtime waits until the function finishes its job, then returns with the result, and the code after the function will be executed right after this.

Synchronous execution

To understand it, let's see the following example:

print("(1) Hey!")  
DispatchQueue.main.async {  
  print("(2) How are you?")
}
print("(3) Fine thanks, and you?")  

What do you think the result will be?🤔

Surprisingly, the conversation will mostly go like this:

(1) Hey!
(3) Fine thanks, and you?
(2) How are you?

As you can see, the second print() call was just executed after the third, because the runtime didn't wait until the second finished. But why is this good for us? Think of a job that takes significant time to perform and you don't even need the result immediately - for example, compressing an image or sending a message using network communication. A job like this would just block the queue it's running on, which degrades the user experience. In this case, you can call it asynchronously, so the rest of your application's job can be done while the lengthy job is also running. ✅

DispatchQueue

Tasks can be assigned to three types of queues:
1. Main: a serial queue, responsible for all UI tasks.
2. Global: a system-controlled queue with concurrent task execution. Used for non-UI methods with an optional QoS class parameter.
3. Custom queue: serial or concurrent queue which can be created by the developer for implementing custom behaviors, like using dispatch barriers.

The key problem

The basics are now clear, so we can jump into the more complicated parts.

Currently, getting the available computers and adding or removing a new one at the same time is absolutely possible. Let's see what happens in this case.

Readers-writers problem

Adding a new computer by using the add(_ computer: Computer) method simply adds a new element to the array. Calling the getComputers() method creates a copy of the availableComputers array and returns it.

The first of these operations can happen in an asynchronous way, so doing both at the same time results in getComputers() returning a false result, because the array which is being copied doesn't contain the newest value that is currently being added.

Readers-writers problem

Now we are facing with the readers-writers problem which is a classic concurrency problem, but luckily it is easy to handle with GCD.

To fix this issue, we've got one thing to do: making sure that nobody can read the array until the result is added. But how can we guarantee that different queues can't call our methods at the same time?🧐
The bad news is we can't, but if we dig a level deeper, we can force our methods to always use one separated queue with only this responsibility in the whole application.

Making a custom queue

This solution needs a custom queue which will execute our jobs. We define a concurrent queue to make sure that multiple reads are allowed at the same time:

private let queue =  
DispatchQueue(label: "com.autsoft.norberthorvath.computerstore",  
              attributes: .concurrent)

A custom queue needs a unique identifier in the label parameter. Now this queue is able to do everything we need, for example reading our array:

func getComputers() -> [Computer] {  
  var computers: [Computer]!
  queue.sync {  // reading always has to be sync!
    computers = availableComputers.sorted { $0.constructionYear < $1.constructionYear }
  }
  return computers
}

Important note: reading always have to be synchronous, otherwise the runtime won't wait until the function returns!

The add(_ computer: Computer) method is a bit more complicated. Remember, this one led us to the readers-writers problem, so it should be somehow executed on the queue as if it was serial to avoid false reading.

Dispatch barriers

To solve this problem, GCD has dispatch barriers which will help us write this method. Inserting a barrier to a write operation ensures that no other tasks will be executed until the writing is done.

The queue acts like it was serial while executing a barrier task

A barrier task can be made by using the .barrier flag:

func add(computer: Computer) {  
  queue.async(flags: .barrier) {
    self.availableComputers.append(computer)
  }
}

Doing the same at the remove(_ computer: Computer) method makes sure that deleting is also safe.

With these little tweaks our singleton class with the computer stock is finally thread safe, so now we can sit back and crack open a cold one. 🎉

Overall

As you can see, improving our application's performance can lead to difficulties. In this article we've faced a basic R/W problem in a singleton class. The tutorial gave you a comprehensive review of the basic concepts of GCD and a simple solution that can be used in many different scenarios.

With GCD, you can do many more tricks to make your application faster. But I'll leave them for the next part.