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.
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.
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.
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:- Main: a serial queue, responsible for all UI tasks.
- Global: a system-controlled queue with concurrent task execution. Used for non-UI methods with an optional QoS class parameter.
- 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 theadd(_ 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.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.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.
Comments
Post a Comment