iOS Concurrency in a Nutshell (Ep-III)

Dispatch Group, Dispatch Work Item, Notify, Wait, Cancel

Emin DENİZ
Orion Innovation techClub
18 min readMar 5, 2023

--

The previous article ‘iOS Concurrency in a Nutshell’ covered topics like the Main Queue, Global Concurrent Queues, Custom Queues, Quality of Service (QoS), and Target Queues. In this article, we will start to dive into more details and how we can manage multiple queues.

Dispatch Group

You may encounter a situation where you need to fetch data from multiple sources and show it to users. For example, when the application launches you might need to fetch the application config from an API, and the user config from DB. Even you can fetch some images to show on your welcome screen. Depending on your preference, you may wait for all of the tasks or some of them to be completed to show the welcome screen. You can see the gif below to have a better understanding of the flow I mentioned.

Sample application launch flow.

In modern applications, it is highly possible that you see such requirements. One approach to achieving this is to send the next request after the current one is completed. For example, in the gif, fetching app config from API starts after fetching user data from DB is completed. Also getting the welcome screen image from the CDN starts after the fetching app config from API. Even though this flow is valid you might guess why we don’t prefer this approach. Depending on network quality second and third steps might take time. We prefer to start those requests immediately to show Welcome Screen faster to the user.

The second approach can be to start each task simultaneously in separate dispatch queues. But in this case, we need a mechanism that tells us all operations are completed. This mechanism is called Dispatch Group.

Using the Dispatch Group we can group multiple tasks and wait for all of them to be completed. We can also continue some of the tasks that are not completed and get notified later. We will discuss this case later. Let’s focus on the first usage which is to wait for all.

The usage of the dispatch group is very simple. We are basically counting the existing dispatch queues. When there is a new task in the queue we are incrementing the count. When it finishes we are decrementing it. When the count is zero it notifies us. Let’s see each API;

  • Enter: When a new task started we call this API and it increments the count.
  • Leave: When a task is completed we call this API and it decrements the count.
  • Notify: Notifies us when all of the tasks are completed.
  • Wait: Waits for the current thread executions until all tasks are completed.

Wait and Notify difference might confuse you but don’t worry I will explain.

Let’s see some examples.

Example-1

For this example purpose, I created a simple API to fetch mock data with the help of mocky.io. I have a single API but I add delays to some of them. The first one has no delay, the second one has 2 seconds delay and the third one has 5 seconds delay. Here is the playground code;

import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

// 1. Sample URLs
let urlNoDelay = "https://run.mocky.io/v3/bf4523b3-1f46-4fa2-a376-5374aadea7e6"
let urlTwoSecDelay = "\(urlNoDelay)?mocky-delay=2s"
let urlFiveSecDelay = "\(urlNoDelay)?mocky-delay=5s"

let loadDataDispatchGroup = DispatchGroup()

// 2. View controller for the playground
class MyViewController : UIViewController {
// 3. Some label to show progress.
let label = UILabel()
override func loadView() {
// 4. Simple UI setup.
let view = UIView()
view.backgroundColor = .white
label.frame = CGRect(x: 150, y: 200, width: 200, height: 20)
label.text = "Starting to fetch...⏱"
label.textColor = .black
view.addSubview(label)
self.view = view

// 6. Fetching all the required app data asynchronously on a global concurrent queue.
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.fetchAppData()
}

}

private func fetchAppData() {
// 7. Starting to execute all the requests one by one.
self.fastRequest()
self.midSpeedRequest()
self.slowRequest()

// 8. Notify block
loadDataDispatchGroup.notify(queue: .main) { [weak self] in
self?.label.text = "Fetch completed 🎉"
}
}

private func fastRequest() {
// 9. Increment the counter by enter function
loadDataDispatchGroup.enter()
print("Fast request started")
Network.shared.execute(urlString: urlNoDelay) { result in
// 9. Decrement the counter by leave function
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Fast request success ✅")
case .failure(_):
print("Fast request fail ❌")
}
}
}

private func midSpeedRequest() {
loadDataDispatchGroup.enter()
print("Mid speed request started")
Network.shared.execute(urlString: urlTwoSecDelay) { result in
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Mid speed request success ✅")
case .failure(_):
print("Mid request failed ❌")
}
}
}

private func slowRequest() {
loadDataDispatchGroup.enter()
print("Slow request started")
Network.shared.execute(urlString: urlFiveSecDelay) { result in
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Slow request success ✅")

case .failure(_):
print("Slow request failed ❌")
}
}
}
}

// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

Here are the key points for this code block that match my comments.

  1. Sample URLs were added to simulate our test cases.
  2. Standard ViewController setup for the playground.
  3. We will use a label to see our progress.
  4. At this step, we are simply initiating the ViewController UI.
  5. At this step, we started to fetch all 3 APIs.
  6. I am starting to fetch all app data in a global concurrent queue with an asynchronous approach to not block UI.
  7. Executing the tasks one by one using the functions.
  8. loadDispatchGroup.notify block is the block we will get the completion information from DispatchGroup. When all 3 APIs execution is completed regardless of success or failure result it will inform us. In the notify block we are setting label text to indicate us all 3 APIs are executed.
  9. Incrementing the counter by calling loadDispatchGroup.enter(). This is required for all 3 APIs. It is important to call enterbefore the leave. Otherwise, you may get crashes.
  10. Decrementing the counter by calling loadDispatchGroup.leave(). This is required for all 3 APIs. It is important to call leave when the task is completed regardless of success or failure. Otherwise, you can’t get notify callback because the system expects the counter becomes zero.

The network class is a simple URLSession wrapper that calls API and returns the result. For the simplicity of the article, I am not adding it here, but you can see it in the GitHub repo of this article series. Let me quickly show you the results.

Screen record of the example-1

As you can see in the logs below all 3 requests starting to execute immediately. As expected the fast one get a quick response, and after that, we saw the mid-speed request. At last, logs indicate the slow request is completed. Until all 3 APIs are completed label text we saw in the UI is ‘Starting to fetch…⏱’, which we set at first. When the last request execution is completed notify block is triggered and it is updating the label text as ‘Fetch completed 🎉’.

We achieved the application requirement, yay! 🥳

Another alternative to implementing such behavior is using the wait function. As the name implies it waits for the current dispatch queue execution. It has similar behavior to the synchronous blocks in Dispatch Queues we saw in previous articles. Let’s see the exact flow with the wait.

Example-2

import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

// 1. Sample URLs
let urlNoDelay = "https://run.mocky.io/v3/bf4523b3-1f46-4fa2-a376-5374aadea7e6"
let urlTwoSecDelay = "\(urlNoDelay)?mocky-delay=2s"
let urlFiveSecDelay = "\(urlNoDelay)?mocky-delay=5s"

let loadDataDispatchGroup = DispatchGroup()

// 2. View controller for the playground
class MyViewController : UIViewController {
// 3. Some label to show progress.
let label = UILabel()
override func loadView() {
// 4. Simple UI setup.
let view = UIView()
view.backgroundColor = .white
label.frame = CGRect(x: 150, y: 200, width: 200, height: 20)
label.text = "Starting to fetch...⏱"
label.textColor = .black
view.addSubview(label)
self.view = view

// 6. Fetching all the required app data asynchronously on a global concurrent queue.
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.fetchAppData()
}

}

private func fetchAppData() {
// 7. Starting to execute all the requests one by one.
self.fastRequest()
self.midSpeedRequest()
self.slowRequest()

// 8. Waiting the current dispatch queue
print("Waiting the current dispatch queue ✋")
loadDataDispatchGroup.wait()
print("Continue to the execution 🤙")

// 9. After execution completed update label in Main Queue.
DispatchQueue.main.async {
self.label.text = "Fetch completed 🎉"
}
}

private func fastRequest() {
// 10. Increment the counter by enter function
loadDataDispatchGroup.enter()
print("Fast request started")
Network.shared.execute(urlString: urlNoDelay) { result in
// 11. Decrement the counter by leave function
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Fast request success ✅")
case .failure(_):
print("Fast request fail ❌")
}
}
}

private func midSpeedRequest() {
loadDataDispatchGroup.enter()
print("Mid speed request started")
Network.shared.execute(urlString: urlTwoSecDelay) { result in
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Mid speed request success ✅")
case .failure(_):
print("Mid request failed ❌")
}
}
}

private func slowRequest() {
loadDataDispatchGroup.enter()
print("Slow request started")
Network.shared.execute(urlString: urlFiveSecDelay) { result in
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Slow request success ✅")

case .failure(_):
print("Slow request failed ❌")
}
}
}
}

// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

The code is almost the same as the previous one except for steps 8 and 9.

  • In step 8 we replace the notify with the wait. This will cause the application to stop the current queue execution until all the tasks are completed in the dispatch group. Remember that we are callingfetchAppData function in a global concurrent queue. So we are blocking this queue only. Don’t ever call the wait function on the main queue, this will block the UI.
  • In step 9 wait should be finished and we are updating the label in the main queue.

Here is the result.

Screen record of the example-2

You can see in the logs we stop the execution until all the tasks are completed. When we called the last leave function the current queue immediately start execution and we see saw the label is updated. You may realize that ‘Slow request success ✅’ is printed after ‘Continue to the execution 🤙’. It might be confusing to you at first but remember that the success logline is under the last leave.

loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Slow request success ✅")
case .failure(_):
print("Slow request failed ❌")
}

Never call the wait function in the main queue, you will block the UI.

So far we have achieved the receive completion of multiple dispatch queues using the dispatch group. After this point, it is possible that you heard such a sentence from your product owner. “It takes too much time to show Welcome Screen. We don’t have to wait for all the requests completed. Let’s show the Welcome Screen to the user after some period of time. When remaining requests are completed later we can just update the UI depending on the result.” This is a totally reasonable request and we can easily implement it with the wait function with the result. Let me show it to you.

Example-3

import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

// 1. Sample URLs
let urlNoDelay = "https://run.mocky.io/v3/bf4523b3-1f46-4fa2-a376-5374aadea7e6"
let urlTwoSecDelay = "\(urlNoDelay)?mocky-delay=2s"
let urlFiveSecDelay = "\(urlNoDelay)?mocky-delay=5s"

let loadDataDispatchGroup = DispatchGroup()

// 2. View controller for the playground
class MyViewController : UIViewController {
// 3. Some label to show progress.
let label = UILabel()
override func loadView() {
// 4. Simple UI setup.
let view = UIView()
view.backgroundColor = .white
label.frame = CGRect(x: 20, y: 200, width: 300, height: 20)
label.text = "Starting to fetch...⏱"
label.textColor = .black
view.addSubview(label)
self.view = view

// 6. Fetching all the required app data asynchronously on a global concurrent queue.
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.fetchAppData()
}

}

private func fetchAppData() {
// 7. Starting to execute all the requests one by one.
self.fastRequest()
self.midSpeedRequest()
self.slowRequest()

print("Waiting the current dispatch queue ✋")
// 8. Waiting the current dispatch queue with timeout value
let waitResult = loadDataDispatchGroup.wait(timeout: .now() + .seconds(3))

// 9. Wait result block inform us either all executions are completed or timeout ocurred.
switch waitResult {
case .success:
// 10. All requests are completed in timeout period.
print("All of the requests are executed in given time 🤙")
DispatchQueue.main.async {
self.label.text = "All requests are completed 🎉"
}
case .timedOut:
// 11. Not all the requests are finished so far.
print("Exection completed with time out ⚠️")
DispatchQueue.main.async {
self.label.text = "Fetch partially completed 🎉"
}

}

}

private func fastRequest() {
// 12. Increment the counter by enter function
loadDataDispatchGroup.enter()
print("Fast request started")
Network.shared.execute(urlString: urlNoDelay) { result in
// 13. Decrement the counter by leave function
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Fast request success ✅")
case .failure(_):
print("Fast request fail ❌")
}
}
}

private func midSpeedRequest() {
loadDataDispatchGroup.enter()
print("Mid speed request started")
Network.shared.execute(urlString: urlTwoSecDelay) { result in
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Mid speed request success ✅")
case .failure(_):
print("Mid request failed ❌")
}
}
}

private func slowRequest() {
loadDataDispatchGroup.enter()
print("Slow request started")
Network.shared.execute(urlString: urlFiveSecDelay) { result in
loadDataDispatchGroup.leave()
switch result {
case .success(_):
print("Slow request success ✅")

case .failure(_):
print("Slow request failed ❌")
}
}
}
}

// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

Notice that after step 8 our flow changed. Let’s see them one by one.

8. Now we pass a parameter called timeout to the wait function. In this sample, we will wait for 3 seconds. Keep in mind that this is just a timeout value. If we use 20 here we don’t have to wait for 20 sec to update the UI. The slowest request should take 5 seconds, so it is expected for us to see a label update in 6–7 sec even if we use 20 as a timeout.

9. The wait result will be triggered by the system either all executions are completed or a timeout occurred. We can see the result in a simple switch case.

10. In case the result is successful that means all the requests are completed given the timeout period.

11. In case the result is timed out that means time out occurred and all the executions can’t be completed.

Let’s see the outputs for 3 seconds of time out.

Screen record of the example-3

You can see the “Execution completed with time out ⚠️” log before the “Slow request success ✅” log. After 3 seconds we receive the wait result and it tells us we have a timeout which means all requests are not executed. We build the UI according to that input and set the label text to “Fetch partially completed 🎉”. In this case, the user won’t wait more than 3 seconds to see Welcome Screen. Also, after the last execution is completed, we can see the result. That means we can use the information we get from the last request for later usage purposes.

Dispatch Work Item

So far we have seen that with the help of DispatchQueue and DispatchGroup, we can execute tasks in parallel in a block of code. What if you want to start to execute that block of code later and have the capability to cancel?

When you go to the documentation of the DispatchQueue.async and DispatchQueue.sync you may release both of them has a parameter called DispatchWorkItem. For example here is a simple dispatch block we used so far.

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

If you press Command (or Cmd) ⌘ button and click on the async part and Jump to Definition you will see the GCD API.

Jump to definition in Xcode

Here is the API you will see.

@available(macOS 10.10, iOS 8.0, *)
public func async(execute workItem: DispatchWorkItem)

Basically, when we dispatch something to the GCD with the simple syntax it creates a DispathcWorkItem under the hood. That means if we create DispatchWorkItem and put a code block, we can dispatch it later. If you look at the documents you can see that DispatchWorkItem has the following functions. I will start with the functions you already know so far.

  • Wait: Waits for the current thread executions until all tasks are completed.
  • Notify: Notifies us when all of the tasks are completed.
  • Perform: It executes the work item synchronously in the current thread.
  • Cancel: Cancels the current work item asynchronously.
  • IsCancelled: Boolean value indicating whether the work item has been canceled.

Wait and notify is obvious because we already discussed this in the previous section. In my opinion, perform is also obvious. If you want to use execute code block synchronously in a current thread you can use it. I will give an example of that in this article. The cancel is the most important function for this section and allows us to stop execution unless it started. This is critical unless and I will give an example in this article. Let’s start with basic cancellation.

When I start to learn the cancel concept I couldn't imagine the use cases where I need this. Let me share some of them with you before diving into the code. In my opinion, this will show you the importance of the cancellation.

  1. Search functionality: Let’s say you have an app with a search bar that allows the user to search for items in a large database. When the user enters a search query, you might want to perform the search asynchronously in the background using a DispatchWorkItem. However, if the user enters a new search query before the previous one has been completed, you'll want to cancel the previous work item to avoid unnecessary processing and improve performance.
  2. Media processing: If you’re processing large media files (such as videos or images) in the background, you might want to allow the user to cancel the processing if it’s taking too long or if they change their mind. This could be useful in a video editing app, for example, where the user might want to cancel the processing of a large video file if it’s taking too long.
  3. Network requests: When making network requests, you might want to allow the user to cancel the request if it’s taking too long or if they change their mind. For example, if you’re downloading a large file or performing a complex API request, you might want to allow the user to cancel the request if they decide they don’t want it anymore.
  4. Data synchronization: If your app is syncing data with a server, you might want to allow the user to cancel the sync if it’s taking too long or if they change their mind. This could be useful in a productivity app, for example, where the user might want to cancel the sync if they need to leave the app or if it’s taking too long.

We can of course increment the number of use cases but I assume you understand the concept. Let’s grab the first use case and build a simple search that can be canceled.

Example-4

import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

/// Ep-3 Example-4
/// DispatchGroup with wait and timeout
///

// 1. Sample URL that fecthes movie list
private let moviesListAPI = "https://run.mocky.io/v3/1249cf39-2dfa-4ee3-81aa-b4d9676b1f66"


// 2. View controller for the playground
class MyViewController : UIViewController {
// 3. A Label and a text field for search box and result
let searchResultLabel = UILabel()
var searchTextField: UITextField!

// 4. Creating a global work item to be able to cancel.
var movieSearchWorkItem: DispatchWorkItem?

override func loadView() {

let view = UIView()
view.backgroundColor = .white

// 5. Label init to show result
searchResultLabel.frame = CGRect(x: 20, y: 150, width: 300, height: 20)
searchResultLabel.text = "Search result:"
searchResultLabel.textColor = .black

// 6. Text field init and styling
searchTextField = UITextField(frame: CGRect(x: 20, y: 200, width: 300, height: 40))
searchTextField.placeholder = "Type to search 🔎"
searchTextField.borderStyle = UITextField.BorderStyle.roundedRect
searchTextField.backgroundColor = .lightGray.withAlphaComponent(0.5)
searchTextField.delegate = self

view.addSubview(searchResultLabel)
view.addSubview(searchTextField)
self.view = view

}

// 7. This is the function we will call when user type something.
func searchInMovies(textToSearch: String) {
// 8. Cancel previously created work item if exist.
movieSearchWorkItem?.cancel()

// 9. Create a local work item
let workItem = DispatchWorkItem {
print("Starting to search for text: \(textToSearch)")
// 10. Starting to fetch data from API
Network.shared.execute(urlString: moviesListAPI) { result in
switch result {
case .success(let response):

print("List fetched from API successfully, \(response)")
// 11. In case response successful search the text that user wants to search.
let result = JSONHelper().searchStringInJSONArray(jsonArray: response, searchText: textToSearch)

DispatchQueue.main.async {
// 12. Depending on the search reult update the UI.
if let result = result{
self.searchResultLabel.text = "Search result: \(result)"

}else {
self.searchResultLabel.text = "Search result: Not found 🤷‍♂️"
}
}

case .failure(_):
print("Failed to fetch API ❌")
}
}
}

// 13. Storing the newly created work item reference
movieSearchWorkItem = workItem
// 14. Starting the execute Work item with 1 sec delay.
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: (.now() + .seconds(1)),
execute: workItem)
}
}

// 15. UITextFieldDelegate extension to detect user typing
extension MyViewController : UITextFieldDelegate {
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if textField == searchTextField {
if let textToSearch = textField.text?.appending(string){
// 16. Call searchInMovies function to start search operation.
searchInMovies(textToSearch: textToSearch)
}
}
return true
}
}

// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

Here are the key points for this code block that match my comments.

  1. I just created a new mock API that returns a movie list to us.
  2. View controller for the playground like the previous example.
  3. In this example, I add a text field and a label. The text field will be used for search purposes and the label will show us the search result.
  4. MovieSearchWorkItem will hold the dispatch work items we created locally. We can use it to cancel operations.
  5. Label init to show results.
  6. Text field inits and styling.
  7. This is the function we will call when the user types something. It will be called from the UITextFieldDelegate extension.
  8. In this step, we are canceling work item that hasn’t been started yet.
  9. Creating a new DispatchWorkItem to start the search operation.
  10. Starting the fetch data from the API.
  11. In case the response is successful search for the response with the helper function. The helper function will convert the response to a string array and search for the result in the array. In case it exists it will return the first match. If not it will return nil. In a production-quality application, I wouldn’t implement something like that. I want to show you some functionality with less code as much as possible.
  12. Depending on the search result show the search result or not found label.
  13. Store the locally created DispachWorkItem in the globally created movieSearchWorkItem. This might seem odd to you because you might think “Why do you create a new workItem variable in step 9, you can use assign the block to the movieSearchWorkItem.”. In the next step, we will dispatch the locally created workItem not the globally created movieSearchWorkItem. Because with this approach GCD put the new operation in a DispatchQueue and executes it at a later time. We use the global variable just to hold the last DipatchWorkItem’s reference in case cancel.
  14. Starting the execute Work item with 1 seconds delay.
  15. UITextFieldDelegate extension to detect user typing
  16. We call the searchInMovies function to start the search operation.

Let’s see the outputs.

Screen record of the example-4

As you realize with the ‘Starting to search for text’ log and UI change, we won’t search every time user types something. We fetch the data from the API and search when the user stops typing and 1 sec is passed. This is a perfect example of how we can use the cancel operation.

As I mentioned previously, the cancel function can stop the execution of the task unless it hasn’t started yet. So if a task is started calling the cancel won’t stop the execution. Let me show you an example.

Example-5

// Create Work item
let workItem = DispatchWorkItem {
// Start a basic for loop
for i in 0..<5 {
print(i)
// Sleep thread for simulate some work
sleep(1)
}
}

// Start the work item in queue
let queue = DispatchQueue.global()
print("Starting the work item")
queue.async(execute: workItem)

// Cancel the work item.
print("Cancelling the work item")
workItem.cancel()

The code below above fairly simple but let me explain quickly. We have a work item and inside the work item block, we have a for loop. In each iteration, we print the index and put a sleep function to stop the current thread. So unless execution won’t stop we expect this function takes 10 sec to complete. At the end of the code block, you can see the cancel API call. Let me show you the output.

Starting the work item
0
Cancelling the work item
1
2
3
4

Even though we canceled the work item it is already started. Remember that cancel can only stop work items that haven’t been started. So how can we fix such a case? With the help of isCancelled.

// Create Work item
let workItem = DispatchWorkItem {
// Start a basic for loop
for i in 0..<5 {
// Checking is work item cancelled
if workItem.isCancelled {
print("Task was cancelled")
return
}
print(i)
// Sleep thread for simulate some work
sleep(1)
}
}

// Start the work item in queue
let queue = DispatchQueue.global()
print("Starting the work item")
queue.async(execute: workItem)

// Cancel the work item.
print("Cancelling the work item")
workItem.cancel()

As you can see we just add if block that checking the task is canceled or not. Here is the result.

Starting the work item
0
Cancelling the work item
Task was cancelled

As you see right after we called the cancel function next iteration won’t b resumed.

Summary

In this article of the series, we discussed important topics such as DispathchGroup and DispatchWorkItem. 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!

Articles in the series

--

--