iOS Concurrency in a Nutshell (Ep-II)

Emin DENİZ
Orion Innovation techClub
16 min readFeb 26, 2023

Main Queue, Global Concurrent Queue, Custom Queue, Quality of Service (QoS), Target Queue, Examples

The previous article ‘iOS Concurrency in a Nutshell’ covered fundamental topics like concurrency, parallelism, and GCD (Grand Central Dispatch). In this article, we will start to dive into concurrency details.

Types of Dispatch Queues

As I mentioned in the previous article, GCD executes the task we submitted with dispatch queues in a worker pool (pool of threads). Depending on the type of dispatch queue GCD prioritized the execution of the tasks. Fundamentally we have 3 types of dispatch queues in GCD.

  • Main queue
  • The system provided global concurrent queues
  • Custom queues

1. Main Queue

The main queue is a system-created serial queue that uses the main thread. If we want to update UI, we have to use the main queue because UI is tied to the main thread.

If you don’t create any queue at all in XCode you are using the main queue in a synchronous manner.

Features:

  • System Created
  • Serial
  • Uses main thread
  • UI tied to the main queue

2. Global Concurrent Queue

Global concurrent queues are system-created concurrent queues. These types of queues can not use the main thread.

Features:

  • System Created
  • Concurrent
  • Can’t use the main thread.

Global concurrent queues can serve most of the concurrent execution needs we have.

// Simple Global Concurrent Queue example 
DispatchQueue.global().async {
print("This task is executing in Global Concurrent Queue")
}

GCD can create multiple concurrent queues for us without exposing all the details. Priorities of those queues are decided using QoS (Quality of Service).

Quality of Service (QoS)

The main purpose of using GCD is to not block users when there is a time-consuming task. Let’s assume that you want to fetch data from an API or local DB. Most of the time those tasks can be executed in less than a second But depending on unexpected parameters like the size of the data or network quality this can take much more time. During that period we don’t want to block the user so we can use DispatchQueue.main.async block. This will solve the freezing UI issue but we are still executing a time-consuming task on the main thread. We don’t prefer to have too much load on the main thread to have better app performance. To prevent this we can use Global Concurrent Queues. But as I mentioned above those are system-created queues (Pool of queues). You may use global concurrent queues in your application in multiple places. Also, other applications can use those queues as well. For example, when your application tries to fetch data from an API the user can listen to music from Spotify. Both your application and Spotify can use global concurrent queues.

How can iOS prioritize which operation is more important?

When GCD tries to distribute the tasks to the correct position.

The answer is Quality of Service. Using predefined enums iOS decides which operation needs to be executed earlier. QoS is implemented as a set of four levels:

  1. User-Interactive: This is the highest priority level and is used for tasks that require immediate user interaction, such as UI updates or animations. The system dedicates a large number of resources to this QoS level to ensure that the task is completed quickly and smoothly.
  2. User-Initiated: This level is used for tasks initiated by the user, but are not as time-sensitive as user-interactive tasks. Examples of user-initiated tasks include loading data from a network or performing a search.
  3. Utility: This level is used for tasks that are not initiated by the user, but are still crucial for the app to function correctly. Examples of utility tasks include pre-fetching data or generating thumbnails for images.
  4. Background: This is the lowest priority level and is used for tasks that can run in the background without impacting the user experience. Examples of background tasks include downloading updates or performing backups.

The table below summarises in which conditions you should use proper QoS.

Although those are the suggested QoSs, there are 2 more possible types. If you don’t define any QoS GCD uses the default which falls between user-initiated and utility. Also, we have unspecified which is the absence of QoS information. Unspecified has the lowest priority. Setting the QoS information is very is like the example below.

DispatchQueue.global(qos: .userInitiated).async {
print("This is a User initated task")
}

Let’s see an example to see how GCD prioritizes the QoS.

Example-1

DispatchQueue.global(qos: .background).async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified

print("---- First loop start ----- qos: \(qos)")
for i in 0...5 {
print("Value is \(i)")
}
print("---- First loop end -----")
}

DispatchQueue.global(qos: .utility).async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified

print("---- Second loop start ----- qos: \(qos)")
for i in 6...10 {
print("Value is \(i)")
}
print("---- Second loop end -----")
}

DispatchQueue.global(qos: .userInitiated).async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified

print("---- Third loop start ----- qos: \(qos)")
for i in 11...15 {
print("Value is \(i)")
}
print("---- Third loop end -----")
}

DispatchQueue.global(qos: .userInteractive).async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified

print("---- Fourth loop start ----- qos: \(qos)")
for i in 16...20 {
print("Value is \(i)")
}
print("---- Fourth loop end -----")
}

In the code block above I have 4 tasks that have different QoS types. All blocks are concurrent and asynchronous, so we can’t anticipate the order of execution. But we know that GCD has prioritization. So we can expect that the fourth loop (User interactive) should finish the execution first and the first loop (Background) should finish the execution last. Let’s see the outputs

// ************************* First Run *************************

---- Fourth loop start ----- qos: userInteractive
Value is 16
Value is 17
Value is 18
Value is 19
Value is 20
---- Fourth loop end -----
---- Third loop start ----- qos: userInitiated
Value is 11
Value is 12
Value is 13
Value is 14
Value is 15
---- Third loop end -----
---- Second loop start ----- qos: utility
Value is 6
Value is 7
Value is 8
Value is 9
Value is 10
---- Second loop end -----
---- First loop start ----- qos: background
Value is 0
Value is 1
Value is 2
Value is 3
Value is 4
Value is 5
---- First loop end -----

// ************************* Second Run *************************

---- Third loop start ----- qos: userInitiated
---- Fourth loop start ----- qos: userInteractive
---- Second loop start ----- qos: utility
---- First loop start ----- qos: background
Value is 0
Value is 16
Value is 17
Value is 18
Value is 6
Value is 19
Value is 20
---- Fourth loop end -----
Value is 11
Value is 12
Value is 7
Value is 13
Value is 8
Value is 14
Value is 1
Value is 9
Value is 15
Value is 10
---- Third loop end -----
---- Second loop end -----
Value is 2
Value is 3
Value is 4
Value is 5
---- First loop end -----

// ************************* Third Run *************************

---- Fourth loop start ----- qos: userInteractive
---- Third loop start ----- qos: userInitiated
---- Second loop start ----- qos: utility
Value is 16
Value is 11
Value is 17
Value is 12
Value is 18
Value is 13
Value is 19
Value is 14
Value is 20
Value is 15
---- Fourth loop end -----
---- Third loop end -----
Value is 6
Value is 7
Value is 8
Value is 9
Value is 10
---- Second loop end -----
---- First loop start ----- qos: background
Value is 0
Value is 1
Value is 2
Value is 3
Value is 4
Value is 5
---- First loop end -----

As you can see the order of execution can mix up. But the fourth loop (User-interactive) always ends first. The third loop (User-initiated) follows it, then the second loop (Utility) ends. At last, you can see the first loop (Background) ends the task execution. How many times you run this code block we expect the same order. But keep in mind that we are using the same amount of tasks in each block to demonstrate the system prioritization for QoSs. If the task in the user-interactive block is much more time-consuming it can end later than the other blocks.

3. Custom Queues

As the name implies we can create custom queues for specific use cases to have more control. Using the custom queues we can have a few more parameters. Here is an example of how we can create custom queues.

let customQueue = DispatchQueue(label: "com.myapp.someQueue",
qos: .utility,
attributes: [.concurrent, .initiallyInactive],
autoreleaseFrequency: .workItem,
target: someOtherQueue)

Let’s go over the parameters one by one.

  • The label is the name o of the queue. It is important to have a good label for debugging purposes.
  • The qos is the QoS value that we see in the previous section.
  • Attributes are the place we set specific attributes like concurrent or initially inactive. Queues are serial unless we especially indicate that it is concurrent. If the queue is initially inactive whenever we dispatch some task to the queue it won’t execute immediately. It will execute later point in time.
  • The concept of targetQueue is an advanced topic. But if I need to summarize when you specify a target queue your task will be executed in the target queue you specify. Let’s say you have 3 serial queues A, B, and C. In case you try to dispatch async tasks to those queues you can’t anticipate the order of execution. But if you have a target queue called T and set this T as a targetQueue for the A, B, and C execution will be ordered. Also, all 3 queues won’t use lower QoS than T.
  • The AutoReleaseFrequency is related to ARC. We can define 3 types of the auto-release pool to the queues; inherit, workItem, and never. As the name implies inherit inherits the target queue’s auto-release pool. Work item defines new auto-release pool and. Never is the option you need if you never want to set up an individual auto-release pool.

Let’s see a few examples to understand those concepts better.

Example-2

// Serial Queue
let queueA = DispatchQueue(label: "QueueA",
qos: .utility)

// Concurrent Queue
let queueB = DispatchQueue(label: "QueueB",
qos: .userInteractive,
attributes: .concurrent)

queueA.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- First loop start ----- qos: \(qos), queueName:\(name)")
for i in 0...5 {
print("Value is \(i)")
}
print("---- First loop end -----")
}

queueA.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Second loop start ----- qos: \(qos), queueName:\(name)")
for i in 6...10 {
print("Value is \(i)")
}
print("---- Second loop end -----")
}

queueB.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Third loop start ----- qos:\(qos), queueName:\(name)")
for i in 11...15 {
print("Value is \(i)")
}
print("---- Third loop end -----")
}

queueB.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Fourth loop start ----- qos: \(qos), queueName:\(name)")
for i in 16...20 {
print("Value is \(i)")
}
print("---- Fourth loop end -----")
}

The code above is fairly simple and let’s go over the key points. We have 2 queues, the first one is a serial queue with utility level QoS. The second one is a concurrent queue with user-interactive level QoS. Let’s see the outputs.

---- First loop start ----- qos: utility, queueName:QueueA
---- Third loop start ----- qos:userInteractive, queueName:QueueB
---- Fourth loop start ----- qos: userInteractive, queueName:QueueB
Value is 11
Value is 16
Value is 12
Value is 13
Value is 17
Value is 14
Value is 15
Value is 18
---- Third loop end -----
Value is 19
Value is 20
---- Fourth loop end -----
Value is 0
Value is 1
Value is 2
Value is 3
Value is 4
Value is 5
---- First loop end -----
---- Second loop start ----- qos: utility, queueName:QueueA
Value is 6
Value is 7
Value is 8
Value is 9
Value is 10
---- Second loop end -----

This is a result we can guess at this point. Between 11–20 is run on the concurrent queue and they are on different blocks, so the outputs are unordered. Between 0–10 is run on the serial queue and the outputs are ordered. Also, user-interactive is higher level than the utility so we can see the 20 before the 10.

Let’s change just a single line and add a target queue.

Example-3

// Serial Queue
let queueA = DispatchQueue(label: "QueueA",
qos: .utility)

// Concurrent Queue
let queueB = DispatchQueue(label: "QueueB",
qos: .userInteractive,
attributes: .concurrent,
target: queueA) //******Changed Line******


queueA.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- First loop start ----- qos: \(qos), queueName:\(name)")
for i in 0...5 {
print("Value is \(i)")
}
print("---- First loop end -----")
}

queueA.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Second loop start ----- qos: \(qos), queueName:\(name)")
for i in 6...10 {
print("Value is \(i)")
}
print("---- Second loop end -----")
}


queueB.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Third loop start ----- qos:\(qos), queueName:\(name)")
for i in 11...15 {
print("Value is \(i)")
}
print("---- Third loop end -----")
}


queueB.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Fourth loop start ----- qos: \(qos), queueName:\(name)")
for i in 16...20 {
print("Value is \(i)")
}
print("---- Fourth loop end -----")
}

We just set the target value of queuB to queueA. Let’s see the outputs.

---- First loop start ----- qos: utility, queueName:QueueA
Value is 0
Value is 1
Value is 2
Value is 3
Value is 4
Value is 5
---- First loop end -----
---- Second loop start ----- qos: utility, queueName:QueueA
Value is 6
Value is 7
Value is 8
Value is 9
Value is 10
---- Second loop end -----
---- Third loop start ----- qos:userInteractive, queueName:QueueB
Value is 11
Value is 12
Value is 13
Value is 14
Value is 15
---- Third loop end -----
---- Fourth loop start ----- qos: userInteractive, queueName:QueueB
Value is 16
Value is 17
Value is 18
Value is 19
Value is 20
---- Fourth loop end -----

All the output is magically sorted. Because even the queueB is concurrent when it is targetted to queueA it executed its task in a serial manner. So all the tasks you submitted to queueB will be executed serially because of the target queue. You may realize that order of execution is changed but the QoS of the queueB is the same. Let’s change one more line.

Example-4

// Serial Queue
let queueA = DispatchQueue(label: "QueueA",
qos: .utility)

// Concurrent Queue
let queueB = DispatchQueue(label: "QueueB",
qos: .background, //******Changed Line******
attributes: .concurrent,
target: queueA)


queueA.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- First loop start ----- qos: \(qos), queueName:\(name)")
for i in 0...5 {
print("Value is \(i)")
}
print("---- First loop end -----")
}

queueA.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Second loop start ----- qos: \(qos), queueName:\(name)")
for i in 6...10 {
print("Value is \(i)")
}
print("---- Second loop end -----")
}


queueB.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Third loop start ----- qos:\(qos), queueName:\(name)")
for i in 11...15 {
print("Value is \(i)")
}
print("---- Third loop end -----")
}


queueB.async {
let qos = DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified
let name = String(cString:__dispatch_queue_get_label(nil))
print("---- Fourth loop start ----- qos: \(qos), queueName:\(name)")
for i in 16...20 {
print("Value is \(i)")
}
print("---- Fourth loop end -----")
}

We just changed the QoS value of the queueB to the background. Remember that background is in lower order than the utility. Here are the outputs.

---- First loop start ----- qos: utility, queueName:QueueA
Value is 0
Value is 1
Value is 2
Value is 3
Value is 4
Value is 5
---- First loop end -----
---- Second loop start ----- qos: utility, queueName:QueueA
Value is 6
Value is 7
Value is 8
Value is 9
Value is 10
---- Second loop end -----
---- Third loop start ----- qos:utility, queueName:QueueB
Value is 11
Value is 12
Value is 13
Value is 14
Value is 15
---- Third loop end -----
---- Fourth loop start ----- qos: utility, queueName:QueueB
Value is 16
Value is 17
Value is 18
Value is 19
Value is 20
---- Fourth loop end -----

As you can see the QoS value of queueB is utility even if we set it to the background. But this wasn’t the case when the QoS of queueB was user-interactive. GCD won’t change QoS if the target queue is lower than the existing queue. It only changes the QoS if the target queue’s QoS value is in higher order than the current queue.

Let’s a few more examples of the things we learn so far.

Example-5

/// Serial queue multiple async execution

var value: Int = 20
let serialQueue = DispatchQueue(label: "com.example.serial")
let someImageURL = "https://images.pexels.com/photos/842711/pexels-photo-842711.jpeg"

func downloadImages() {
for i in 1...5 {
serialQueue.async {
let imageURL = URL(string: someImageURL)!
let _ = try! Data(contentsOf: imageURL)
print("\(i) download complete")
}
}
}

downloadImages()

serialQueue.async {
for i in 0...5 {
value = i
print("👨‍💻 \(value) 👩‍💻")
}
}

print("Last Line 🎉")

The code block above has a single serial queue with 2 async execution. downloadImagefunction downloads some random image from the internet in an asynchronous manner. Aso, we are executing a for loop in an asynchronous manner. Here are the outputs.

Last Line 🎉
1 download complete
2 download complete
3 download complete
4 download complete
5 download complete
👨‍💻 0 👩‍💻
👨‍💻 1 👩‍💻
👨‍💻 2 👩‍💻
👨‍💻 3 👩‍💻
👨‍💻 4 👩‍💻
👨‍💻 5 👩‍💻

The last line print is not in a block and it will be executed in the main queue in a synchronous manner. That’s why we see it at first. The queue we have is a serial queue so we are expecting that the results should be ordered and you can see that our expectation is matching with the results. In a serial queue, we are waiting for each task to complete. The outputs I show above may not indicate it clearly so please see the gif below.

This is the playground output screen record of this example. You can clearly see that downloading the images takes time. During that time serial queue won’t execute any other tasks. Until the first image download is complete it won’t start the second.

Let’s change one line of code again.

Example-6

/// Serial queue sync and async execution

var value: Int = 20
let serialQueue = DispatchQueue(label: "com.example.serial")
let someImageURL = "https://images.pexels.com/photos/842711/pexels-photo-842711.jpeg"

func downloadImages() {

for i in 1...5 {
serialQueue.sync { //******Changed Line******
let imageURL = URL(string: someImageURL)!
let _ = try! Data(contentsOf: imageURL)
print("\(i) download complete")
}
}
}

downloadImages()


serialQueue.async {
for i in 0...5 {
value = i
print("👨‍💻 \(value) 👩‍💻")
}
}


print("Last Line 🎉")

In this example, I only changed the first asynchronous block in the function to the synchronous block. Here are the results.

1 download complete
2 download complete
3 download complete
4 download complete
5 download complete
Last Line 🎉
👨‍💻 0 👩‍💻
👨‍💻 1 👩‍💻
👨‍💻 2 👩‍💻
👨‍💻 3 👩‍💻
👨‍💻 4 👩‍💻
👨‍💻 5 👩‍💻

As you can see until the execution of the synchronous block is finished nothing will be executed. Even the print without any dispatch block at the end needs to wait. Let’s what will happen if we have a concurrent queue.

Example-7

/// Concurrent queue with multiple async block.

var value: Int = 20
let concurrentQueue = DispatchQueue(label: "com.example.serial",
attributes: .concurrent)
let someImageURL = "https://images.pexels.com/photos/842711/pexels-photo-842711.jpeg"

func downloadImages() {

for i in 1...5 {
// Multiple task are submitted to queue
concurrentQueue.async {
print("\(i) starting to download")
let imageURL = URL(string: someImageURL)!
let _ = try! Data(contentsOf: imageURL)
print("\(i) download complete")
}
}
}

downloadImages()


concurrentQueue.async {
// Single task submitted queue with for loop
for i in 0...5 {
value = i
print("👨‍💻 \(value) 👩‍💻")
}
}


print("Last Line 🎉")

This is almost the same as example-5 except for the concurrent keyword. Also, I add a download start print to explain the results better. Here are the results.

1 starting to download
3 starting to download
4 starting to download
Last Line 🎉
2 starting to download
5 starting to download
👨‍💻 0 👩‍💻
👨‍💻 1 👩‍💻
👨‍💻 2 👩‍💻
👨‍💻 3 👩‍💻
👨‍💻 4 👩‍💻
👨‍💻 5 👩‍💻
1 download complete
5 download complete
3 download complete
2 download complete
4 download complete

It takes a little time to download the images, so be patient with the gif :)

We know that can’t anticipate the task execution order in concurrent asynchronous queues. We submit the tasks to GCD and the system decides it. But we are seeing that the second block is executed in order, which might seem too weird to you.

In the function, we have a for loop, and dispatching is done inside this for a loop. For the second block, this is the opposite. We are dispatching it and then in the dispatch, we have a for a loop. You probably say that “So what?”. The difference is in the first case we are dispatching 5 times depending on for loop. But in the second case, we are dispatching a single task that is for the block. That’s why we see emojis with numbers ordered, this is just a single task. For the image download, we are submitting 5 tasks and the system decides which one should start first.

In this example, you can see that the image download is unordered started and it finished not related to the order of start. Start order is 1>3>4>2>5 and finish order is 1>5>3>2>4. In the gif, you can see the that system tries to download all 5 images simultaneously. All 5 “starting to download” is printed at first. Sometime later you can see downloads are completed one by one. This is a valuable point to focus on. The system decides which task should start, which is a concurrent queue and the system choose 1>3>4>2>5 order. But task completion is affected by the network. Even if the images are the same the download speed can be changed each second. So we saw that system trying to download 5 images at the same period of time but some of them were completed faster.

Summary

In this article of the series, we started to dive a little more into concurrency in iOS. In each article, you and I learn a lot of things about concurrency and hopefully master it.

I encourage you to play with the topics we talked about in this article. Here is the link to the repository that contains examples in this article.

Take care till we meet again!

--

--