Multithreading in Swift (Part 2)

Ajaya Mati
6 min readNov 17, 2023

--

Photo by Marissa&Eric on Unsplash

In a multithreaded environment, we get the power of parallel computing by tapping into every core of the CPU. As stated “With great power comes great responsibility”, we need to take care of a few things to ensure the correctness of our program.

In my previous article, We talked about the definition and usefulness of Multi-threaded programming. We also used GCD’s own DispatchQueue and DispatchGroup. In this article, we will discuss the issues we might get into in a multithreaded environment.

If you have yet to go through my previous article I recommend reading that once using the following link.

1. Deadlock

When a thread gets blocked and has to wait indefinitely to get unblocked, we get a Deadlock. It can cause hangs and crashes in our application.

One of the key causes of deadlock can be the use of shared resources from multiple threads.

Take an example where 2 resources are there and two different threads need these two resources to complete their execution.

In a situation where these two threads are running in parallel, there may be a case when both of the threads can acquire one resource by themselves.

Now both the threads will never be able to complete their execution because they will be waiting for another resource.

Rather a simple example is mentioned below

Every sync operation blocks the current thread, and will only get unblocked right after the sync block gets executed successfully.

So in the above example our, “Hey” gets printed from the main thread and after that, the main thread gets blocked and waits for the completion of the sync block. But again we are trying to print (“Reader”) from the main thread only, and this operation will never be able to execute as our main thread is already blocked. And this leads to a deadlock. The application will crash and will get a bad access error.

Read more about deadlock here.

2. Race Condition

The order of events/executions is essential to verify the currentness of a program. When dealing with multiple threads one has to ensure the order of events/executions remains the same we expect it to be.

Take this example.

In the class, Name we have a member setFullName which takes two arguments, firstName and lastName . Then we concurrently update an instance name by calling the method.

Now, one should expect to get Hi iand Hello i (same suffix) in the console. But if you run this piece of code, you’ll see a very interesting outcome. The result might be different for every time we execute it. The suffix integer of Hi and Hello might be different.

Why is it happening? We are parallelly updating the firstName and lastName from different threads.

Consider two Thread thread1 and thread2 . Now we call the method from both of the threads parallelly.

Here we see the two events updating firstName and updating lastName order matters.

The above situation is called Race Condition. To avoid race conditions, any operation on a shared resource must be executed atomically.

self.firstName = first
self.lastName = last

These two statements are our critical section, and the execution of a critical section must be atomic.

Atomicity refers to the execution of a set of statements as a single unit. No other thread will be able to interfere in between. i.e executing the section of statements on only one thread at a single time.

Atomicity can be achieved using a locking mechanism or a counting semaphore.

For more on Race condition checkout this link.

3. Data Race

Data Race or Access Race happens when two or more threads try to access a shared mutable resource while one of the threads is writing to it.

Mutating a memory location while accessing it from a different thread gives a Thread 7: EXC_BAD_ACCESS (code=1, address=0x10) error.

The below scenario is an example of a Data Race. Here we are concurrently accessing and updating the “aKey” key-value of the Dictionary from multiple threads.

The dictionary is our shared resource here. If you try to run this piece of code you’ll end with a crash.

Data races happen with all types of resources. Here I took the example of a Dictionary, but it also happens with String, Array and even with Int, Double.

You are probably wondering why the Race Condition example didn’t give any error/crash. There also we were mutating the firstName & lastName from multiple places. Read more about this swift value type thread safety here.

Race Condition is about the data integrity/validation & Data Race is about safely accessing shared resources across multiple threads. Both together makes up thread safety.

To check for thread safety issues in a real application, enable the thread sanitizer.

4. Priority Inversion

When a low-priority task delays the execution of a high-priority task, we get priority inversion. This happens when a low-priority thread gets hold of resources or locks a critical section which is also needed by a high-priority thread.

In an ideal scenario, the high-priority tasks should be able to finish using the resource/critical section before the low-priority tasks.

In the above example, both operations are happening in parallel to each other as they are async operations. But you’ll see that our high-priority utilityQueue tasks are getting done before the backgroundQueue tasks despite the relative order of calling. The output is expected.

Now if we add a third statement trying to do a task in sync with starterQueue from the backgroundQueue , we’ll end up with a different rather unexpected output.

You can see our most of the higher priority tasks are done in the last after the low priority tasks.

To understand why it’s happening, we have to understand what GCD does under the hood while a low-priority task blocks a high-priority task (through sync block or mutex lock). It temporarily raises the qos(Quality of Service) of the thread containing the low-priority task.

So in the above example, you can see backgroundQueue qos temporarily raised to userInteractive as the containing starterQueue has a qos of the same, because of the sync operation.

Again if we changed the example.

We can see that task 0(backgroundQueue) ends first in comparison to
task 1 (utilityQueue) because of inversion in priority. But after the sync block, the operations work in order of their priority
i.e. task 3 (utilityQueue) then task 2(backgroundQueue).

Summing Up

In this article, we learned about the issue in a multithreaded environment.

I highly recommend you to go through these links for a better and broader understanding.

  1. https://www.avanderlee.com/swift/deadlocks-detecting-solving/
  2. https://www.geeksforgeeks.org/introduction-of-deadlock-in-operating-system/
  3. https://forums.swift.org/t/understanding-swifts-value-type-thread-safety/41406
  4. https://www.avanderlee.com/swift/race-condition-vs-data-race/
  5. https://www.avanderlee.com/swift/thread-sanitizer-data-races/

--

--

Ajaya Mati

iOS Engineer@PhonePe, IITR@2022 | Swift, UIKit, Swift UI