Async in Swift 301
Deep dive in multi-threading using DispatchQueues and other tools. Solving some common problems that comes with multi-threading.
Race Conditions & Data race
Definitions for both from Wikipedia
A race condition arises in software when a computer program, to operate properly, depends on the sequence or timing of the program’s process or threads.
The precise definition of data race is specific to the formal concurrency model being used, but typically it refers to a situation where a memory operation in one thread could potentially attempt to access a memory location at the same time that a memory operation in another thread is writing to that memory location, in a context where this is dangerous.
Let see some code for basic race condition. As you can see, there are two seperate DispatchQueues. They both change userCount. In first queue, it checks “if userCount == 1000”. If yes, it will increment. At that moment, userCount is 1000. Before queue1 increments userCount, another thread (in this situation it is queue2) changes userCount to 0. Then, queue1 continues. But userCount is not 1000 anymore and after increment, it becomes “1”. We were expecting 1001. However, it prints “1”. Well, this is called Race Condition.
queue1
queue2
queue2: 0
queue1: 1
It is not always easy to catch these kind of bugs. But once you get it, it becomes easier. Let’s continue and see another example you might face in real world. This time it has Data Race, too.
Let’s assume, you are developing new feature for a social networking app and with this new feature, users will be able to pick and upload multiple images. If you do this operation synchronously instead of asynchronously, it will theoretically take 10x time instead of x time. Of course calculation is theoretically and assuming user picks 10 images.
I have executed the code three times and here what it prints;
["8F329352-19F4-42E7-84AB-D6A42C4E5EE8"]
Total count: 1["AABAC902-BA49-43C8-B799-A7CE7E94F6C3", "832ABC73-081E-498E-B738-988D20EE6EE8", "E8B2AAE9-5483-4392-A11D-5E4045C8B45C", "C7FE0127-830D-44FC-BDC3-F1458CE7B948", "6CE99FD3-375B-4C68-85CC-0FE427D14452", "778200EC-B048-4681-95F3-4E43E1EB82FA", "9D3BAD38-400D-4A78-9C56-36899F3E4BAF", "4E341FC3-4534-41C1-8456-A8AB782955CE"]
Total count: 8["AA115891-C42C-4E56-9C3B-F0A5D0993FE3", "E36FBF48-9101-4E7E-993E-4670BE8A3574", "7F2BA746-50E8-4A0F-9DAE-416B2E1E7AF0", "E1ED70DB-34E8-4551-AA47-2CF507168308", "3D6BC030-C2B5-497B-83C2-9EE87BCAD5C2", "1C8320B0-6955-42E7-91D7-CA6256768C85"]
Total count: 6
And if you run the code, try a few times, you might even get an error.
Well, something is weird. Because we are uploading 10 images, but we get 1 id back. Sometimes 8, sometimes 6 … You might be getting all ten of them. Why does it change? In real world app, this can happen, because network might fail, or server might not respond to some images, or some process take too much time which might cause a time-out error. But we are not really doing any networking. We are simulating it and we know all of our request returns success. So, how did this thing happen? This is what happens when multiple tasks or threads try to change data at the same time. In our case, we have 10 concurrent tasks executing. When all finishes, they all try to write “imageIds” array and Data Race occurs. These problems could all be prevented if “append” method was Thread-Safe.
Thread Safety
Basically, When a thread is modifying or reading a shared data, no other thread can change it.
Here is wiki’s definition of thread-safety;
How can we make our code Thread-Safe in Swift?
There are many options in the standart library, I will fix the problem with some of them and try to explain what it means. We are going to use NSLock, DispatchQueue, Barrier and DispatchSemaphore.
NSLock
It is a simple mechanism that provides thread-safety. To put it simply, Whosoever gets the lock first, will have the right to modify data. Others will have to wait until the task is finished and the lock is “unlocked”.
Here is the documentation, and listen when Apple warns about something.
Let’s see the code: How to solve it with NSLock;
When you acquire a lock, you must release it. Otherwise, it might create more problems, then it solves.
In line 17, We release the lock in Defer statement. Defer is very useful in this kind of situation. It prevents the cases that might happen because of forgetting the “unlock”.
DispatchQueue
You can solve this problem with a DispatchQueue, too. And since you are familiar with it, you might find it easier.
Here is the code, how to solve it with DispatchQueue;
DispatchQueue + Barrier
In previous code, we have used a serial queue. If you want the same behaviour with a concurrent queue. You need to use a Barrier. Just create another concurrent queue and send .barrier flag when using async{…}
switch result {
case .success(let id):
anotherQueue.async(flags: .barrier) {
imageIds.append(id)
}
case .failure(let error):
print(error)
}
DispatchSemaphore
It is similar to NSLock. But with semaphore, you can control how many processes can access to the shared data. In line 4, DispatchSemaphore(value: 1) we have allowed only one process. It can be different for different scenarios. Apple documentation for DispatchSemaphore.
Try removing line 15 (semaphore.wait()) and add that line like below. Then execute the code. You will see that it will behave as if uploading processes were synchronous.
for image in images {
semaphore.wait()
API.upload(image) { ...
This was just creating Race Condition & Data Race, and solving these problems without any third party solutions. Multi-threading problems can be solved with different tools like Async/Await, Coroutines, Promises, Futures, FRP …etc. But understanding of threading and memory access is very crucial. In next part, I will write about other Multi-threading problems and What Swift has to solve them. Any feedback would be appreciated. Please, reach me out for your questions.