DEV Community

loading...
Cover image for DispatchGroup in Swift

DispatchGroup in Swift

Fernando Martín Ortiz
Senior iOS Engineer at Parser Digital | ortizfernandomartin@gmail.com
・6 min read

Introduction

When we talk about async tasks in iOS development, we are talking about code that will run in a separate thread.

Why do we do this? Well, to start with, the main thread (represented by the main queue in Grand Central Dispatch) is the only thread that can run UI related code. You can't update a UITableView from a background thread, for instance. So, imagine what would happen if we wait in the main thread for a networking call to complete. If that is the only code that is running in the main thread, the app would freeze, resulting in a poor user experience.

That's why we delegate time consuming tasks (like waiting for a http call to complete) or computationally heavy tasks (like applying CoreImage filters) to background threads. This way, the main thread isn't blocked while those tasks are being executed, so the user can continue interacting with the app while we do the heavy work in the background.

After the async task completes, we can update our UI or do whatever we need with its result.

ASync-Page-2

Sync vs Async

Coding in async contexts is more challenging than working purely with sync tasks.
Imagine we have a (nonsense) function to print something in the console:

func say(_ text: String) {
    print(text)
}
Enter fullscreen mode Exit fullscreen mode

And imagine we want to say "Hi" then a couple of phrases and finally "Goodbye". Doing that in sync code is pretty simple and understandable:

say("Hi")
say("I love cookies")
say("My dog is called Emma")
say("I develop iOS apps")
say("Goodbye")
Enter fullscreen mode Exit fullscreen mode

Pretty simple. Each line is written after the other and they follow the order in which we have written them.

Now, let's write an async version of say:

func say(_ text: String, completion: @escaping () -> Void) {
    let delay = Double.random(in: 1...2)
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
        print(text)
        completion()
    }
}
Enter fullscreen mode Exit fullscreen mode

This code does exactly the same but does two other things in addition:

  1. It prints the text after a delay. We use DispatchQueue.main.asyncAfter(...) to introduce that delay. This way, the function is now async. Note that also the delay is randomly generated, which means that we don't know exactly how long it is going to run.
  2. We also pass a completion, which is a closure that will be executed when the function finishes.

Now, why is this type of code hard in the first place? Well, there are many challenges that we will have when working with async code. We will left thread safety for a future article and will describe two possible problems with async code.

Running tasks serially

The first problem is how to simulate the synchronous behavior using asynchronous functions. With synchronous behavior I mean running an async task, waiting for its completion and then run another async task using the first task's result.
This is a pretty common scenario when working with http requests. Maybe we need to execute a first request to login a user and then another request to get its profile details, and then another request to load other information about them, etc. You get the idea. Without performing login, the rest of the http calls couldn't have been executed.

Async-001

For now, the code that can solve this, but that is not optimal in any way this by calling the second function in the completion from the first function. So, in our case, this would be something like this:

say("Hi") {
    say("I love cookies") {
        say("My dog is called Emma") {
            say("I develop iOS apps") {
                say("Goodbye") {}
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

You get the problem here, right? This type of code is commonly called the pyramid of doom, and it doesn't only happen in async code, so beware of it.

The most typical way to solve this problem is by using Combine's Future or PromiseKit's Promise, or by using RxSwift's flatMap. If you're interested on how this works under the hood, I plan to write another article for this case, but for now you can check my code for a simple Promise in this gist, or check this quite old but incredibly mind blowing talk from Javi Soto.

Running tasks in parallel

The second common problem you'll face when working with async code is running tasks in parallel. If we aren't worried about the order in which the async code is executed, we can run all of the async tasks in parallel and finally run something as a completion.

ASync-Page-1

HTTP calls are also a common place where this could happen. Imagine we are accessing to a profile screen for a user. In this case we will probably need to call endpoints for their pictures, posts, personal information, among many others. Does it make sense to wait for the pictures to load before loading their personal information? Of course not. What makes sense in this case is to fire all of the tasks in parallel. After that, we can remove a loading indicator or whatever.
In our example, we can say "Hi" synchronously, then all the other phrases ("I love cookies", "My dog is called Emma" and "I develop iOS apps") in parallel. And after that, we can say "Goodbye".

If you're working with an async programming library, this is also a common scenario where you are surely covered. In Combine you have zip. A similar zip function is present in RxSwift. In PromiseKit you can use when.

Their implementation involves a lot of advanced Swift. In this article, we'll explore another, simpler way of dealing with parallel async code.

Introducing DispatchGroup

Well, here is our simple solution. DispatchGroup is an incredibly useful and simple to use class. It represents a group of asynchronous operations and has a few important methods:

  • enter(): By calling enter, we are saying the DispatchGroup that an async task has began.
  • leave(): leave means that an async task has ended.
  • notify(queue:): we can use notify to configure a closure that will be executed once all the tasks that entered into the DispatchGroup have already left it. There is also a synchronous version of notify that is called wait(timeout:), which blocks the current thread until all the tasks that entered into the DispatchGroup have left it.

Enough theory, let's see how simple this can be in practice.

First, we need to initialize a DispatchGroup:

let group = DispatchGroup()
Enter fullscreen mode Exit fullscreen mode

Pretty simple. Next, we will need to enter into the group when a task has began and leave the group when it has ended. So, for instance, if I'm going to say "I love cookies", I will do this:

group.enter()
say("I love cookies") {
    group.leave()
}
Enter fullscreen mode Exit fullscreen mode

And finally, we will configure notify with .main queue:

group.notify(queue: .main) {
    print("Goodbye")
}
Enter fullscreen mode Exit fullscreen mode

That code will be executed only when every other task has left the group.

So, the complete code for this is:

let group = DispatchGroup()

print("Hi")

group.enter()
say("I love cookies") {
    group.leave()
}

group.enter()
say("My dog is called Emma") {
    group.leave()
}

group.enter()
say("I develop iOS apps") {
    group.leave()
}

group.notify(queue: .main) {
    print("Goodbye")
}
Enter fullscreen mode Exit fullscreen mode

This means that we will say "Hi", then the other three phrases will be printed at any time after that, because they are executed in parallel, and finally we will say "Goodbye".

Of course this is just an example. The say method could also be a networking call, or a database operation, or whatever other real-world async task.

To sum up

We have defined what async code is, and two basic issues we could have when working with it: running tasks serially and in parallel. For running tasks in parallel we saw that DispatchGroup can be a simple solution for that.
I will recommend using a library for this though. What I taught you here is basic async knowledge, but in real world scenarios, this won't be this straightforward, so using a battle tested library is always an advantage when working in production. I personally recommend using Apple's Combine if you can (it is supported in iOS 13 and above) or using RxSwift otherwise
Finally, I can't finish this article without mentioning async-await. With async-await, probably all of this discussion will be a thing of the past. It lets us write async code that can be written like sync code. You can see the proposal (already implemented and that will be released in the next version of swift) here, and even try it in your code following a tutorial like this one.

Discussion (0)