Understanding DispatchQueues

February 14, 2018

If you're developing iOS apps for more than a few weeks, then you have probably dealt with concurrent code before. If you have no background on Operating Systems, you may have asked yourself one of these questions.

Multithreading in general is a difficult thing to fully understand, but understanding how the CPU deals with concurrency is the key to writing good, fast code that does what you expected it to do. Otherwise, you might be abusing your user's CPUs but thinking everything is fine because they are too fast for you to notice that something is wrong.

Before we can answer these questions, we need to take a step back and understand how things work behind the scenes.

What's a Process?

The definition of a process is quite simple: it is a running program. Your app is a process, Slack is a process, Safari is a process, and so on. It contains a list of instructions (your code in Assembly format) and sits there on your disk until the user wishes to run it. The OS will then load that process into memory, start an instruction pointer that tells us which instruction of the program is currently being executed, and have the CPU sequentially execute its instructions until they end, terminating the process.

Address space of a single thread process |- - - - - - - - - - - - - - - - - - - - - - - - - - | | Instructions | |- - - - - - - - - - - - - - - - - - - - - - - - - - - | Global Data | |- - - - - - - - - - - - - - - - - - - - - - - - - - - | malloc'd data (Reference Types) | |- - - - - - - - - - - - - - - - - - - - - - - - - - - | Nothing (Stack and malloc'd data grow towards here)| |- - - - - - - - - - - - - - - - - - - - - - - - - - - | Stack (Value Types (if possible), args, returns) | |- - - - - - - - - - - - - - - - - - - - - - - - - - -

Each process gets its own section of physical memory dedicated to itself. They do not share these addresses with other processes.

I'm reading something on Safari while listening to Spotify. How can the CPU run several processes at the same time?

It can't. What you are experiencing is an illusion caused by the absurd amount of speed a CPU has.

A CPU simply cannot do two things at the same time. Things are slightly different for CPUs with multiple cores, but for simplicity, let's assume we only have one CPU: What happens is that it executes something in Safari, then something in Spotify, then something in iOS, then something in Safari again, and so on. The OS will save whatever the CPU was doing for a specific process in memory (in the form of registers and pointers), decide what will be the next process to run, retrieve what it was doing for that process, have the CPU run it for a while, and repeat. This is called a context switch and it happens very, very quickly, giving the impression it can actually run several things at once. (In CPUs with multiple cores the work can be divided between the cores, actually doing several things at once. However, the same principles apply when all the cores are in use.)

The exact way the OS decides what should be the next process to run is rather complex (read the book at the end of the article if you're interested), but what you should know is that it's possible to dictate manually what's the "priority" of something in our app. (Are iOS's "Quality of Services" starting to making sense now?)

What's a Thread?

Instead of the classic concept of a single thread process that starts at a main() function and ends at some exit() a few lines below, a multi-threaded program has more than one point of execution (each of which is being fetched and executed from). Perhaps another way to think of this is that each thread is very much like a separate process, except for one difference: they share the same address space and thus can access the same data.

Address space of a multi-threaded process |- - - - - - - - - - - - - - - - - - - - - - - - - - | | Instructions | |- - - - - - - - - - - - - - - - - - - - - - - - - - - | Global Data | |- - - - - - - - - - - - - - - - - - - - - - - - - - - | malloc'd data (Reference Types) | |- - - - - - - - - - - - - - - - - - - - - - - - - - - | Nothing (Stack and malloc'd data grow towards here)| |- - - - - - - - - - - - - - - - - - - - - - - - - - - | Stack of Thread 2 | |- - - - - - - - - - - - - - - - - - - - - - - - - - - | Stack of Thread 1 | |- - - - - - - - - - - - - - - - - - - - - - - - - - -

Just like processes, a CPU cannot run two threads at the same time - they are instead targeted by context switches just like processes. The CPU runs something in Safari's Thread 1 (which is doing some UI updates), then something in Spotify's Thread 3 (which is downloading a song), then something in Safari's Thread 2 (which is pinging a DNS), and so on.

iOS: The Main Thread

Your iOS app has several threads. The Main Thread is simply the intial starting point of execution in your app (starting for you at didFinishLaunchingWithOptions). The Main Thread executes a loop every frame (a RunLoop) that draws the current screen if needed, handles UI events such as touches and executes the contents of the DispatchQueue.main. It keeps doing this until the app is terminated. It has extremely high priority - pretty much anything on it gets executed immediatly. That's why you need to route UI code to the Main Thread - by execute some UI-changing code outside of it, your code might start running properly only to suddenly get context switched for several miliseconds because something more important arrived to the OS (like a notification). Your UI updates will then be delayed, giving a bad experience to your users.

However, you can't simply execute everything on the Main Thread. Since this thread deals with everything related to screen draws / UI updates, if you run a huge task on it, it won't be able to do anything else until it ends. That's why we need several threads (points of execution) to begin with.

@IBAction func actionOne(_ sender: Any) { //Button actions are in the Main Thread. //This takes about 5 seconds to finish var counter = 0 for _ in 0..<1000000000 { counter += 1 //The screen is totally frozen here. How can I scroll my screen (an UI action) //If I blocked the thread by doing this meaningless thing? //The scroll action is waiting to be run, but it can't because it's also a Main Thread action. //You can't simply context switch actions on the same thread. //This needs to be run in a different thread. } }

iOS: Background Threads and DispatchQueues

A background thread is anything that is not the Main Thread. They can run alongside the Main Thread (like they were a different process, but remember the definition of a thread!), dealing with complex tasks without interferring with the Main Thread's UI updates. In iOS, the safest way of spawning a background thread is to use DispatchQueues. However, be aware that DispatchQueues are not threads - they are merely queues of closures that will eventually be forwarded to a relevant thread. A DispatchQueue will automatically create and reuse a pool of threads as it finds necessary, abstracting from you the hassle of spawning threads manually and dealing with potential issues of doing so.

The Main Thread will serially run the contents of DispatchQueue.main (that is, action 2 only happens after action 1 ends), while the contents of DispatchQueue.global(qos:) will concurrently (everything at the same time) run into background thread(s) (if there are several actions) with priority equal to the priority of the selected QoS. If you'd like custom behavior (such as a queue that forwards closures to a background thread, but serially), you can create your own DispatchQueue.

Background Queue Priorities (QoS)

By assigning a Quality of Service to an action, you indicate its importance, and the system prioritizes it and schedules it accordingly.

Because higher priority work is performed more quickly and with more resources than lower priority work, it typically requires more energy than lower priority work. Accurately specifying appropriate QoS classes for the work your app performs ensures that your app is responsive as well as energy efficient.

There are a few levels of QoS for background threads for several different kinds of actions, but none with higher priority than the Main Thread (after all, there would be no point to this if a background task blocked an UI update, don't you think?). The Quality of Services are:

Visualizing the impact of different QoS levels

: For work that must be processed instantly.: For work that is nearly instantaneous, such as a few seconds or less.: For work that can take some time, such as an API call.: For work that takes a very long time.

By using Instruments, we can see how the different QoS levels affect the execution of our code.

Heavy task on the Main Thread

@IBAction func actionOne(_ sender: Any) { //We already are in the main thread, but we will use a dispatch operation //to see how long it takes for the task to begin. DispatchQueue.main.async { [unowned self] in self.timeIntensiveTask() } }

The task got executed instantly after I pressed the IBAction, and took about 5 seconds to complete. However, the entire screen was frozen, as we blocked the thread.

Heavy task on an UserInitiated QoS thread

@IBAction func actionOne(_ sender: Any) { DispatchQueue.global(qos: .userInitiated).async { [unowned self] in self.timeIntensiveTask() } }

A new thread spawned, and the task got executed almost instantly after I pressed the IBAction, also taking about 5 seconds to complete. No screen freeze this time! This thread is completely independent.

Heavy task on a Background QoS thread

@IBAction func actionOne(_ sender: Any) { DispatchQueue.global(qos: .background).async { [unowned self] in self.timeIntensiveTask() } }

Just like UserInitiated, a thread got spawned, but in this case, not only it took some time for the task to start - and it took almost 10 seconds for it to end! This lower priority thread had delayed and reduced access to system resources. However, this is good: If you're sending a task to a background QoS queue, it means you don't want to ruin your user's CPU by focusing on it.

Visualizing Serial Queues versus Concurrent Queues

@IBAction func actionOne(_ sender: Any) { DispatchQueue.main.async { [unowned self] in self.timeIntensiveTask() } DispatchQueue.main.async { [unowned self] in self.timeIntensiveTask() } DispatchQueue.main.async { [unowned self] in self.timeIntensiveTask() } }

@IBAction func actionOne(_ sender: Any) { DispatchQueue.global(qos: .background).async { [unowned self] in self.timeIntensiveTask() } DispatchQueue.global(qos: .background).async { [unowned self] in self.timeIntensiveTask() } DispatchQueue.global(qos: .background).async { [unowned self] in self.timeIntensiveTask() } }

DispatchQueue.sync vs DispatchQueue.async

If the concept of a multi-threaded process wasn't mind-boggling enough, we need to careful with the definition of .async and .sync operations.

A common misconception is to think that DispatchQueue.async means executing something in background, and that's not true.

What will be the output on actionOne()?

@IBAction func actionOne(_ sender: Any) { DispatchQueue.main.async { [unowned self] in print("async started") self.timeIntensiveTask() print("async ended") } print("sync task started") timeIntensiveTask() print("sync task ended") } private func timeIntensiveTask() { var counter = 0 for _ in 0..<1000000000 { counter += 1 } }

The answer will always be:

sync task started sync task ended async task started async task ended

If you thought the two tasks would start together, just think about the context of this method: we are dispatching a task to the Main Thread, but actionOne is already on the Main Thread! There's no way a thread can run two sequences of instructions at the same time, that's why we have different threads.

The async task will also only execute after the sync task (and never before) because DispatchQueue.main tasks will only start executing at the end of the Main Thread's RunLoop - which is blocked by our sync task. If actionOne happened to be in a different thread or the async task happened to be in a different DispatchQueue, the tasks would start together in an order dependant to how fast the async task would be dispatched.

What DispatchQueue.async means is: Make sure this task is eventually executed on thread X (main, or any other global background thread depending on what queue you are using), but I don't care about the details. I'll keep doing my stuff.

On the contrary, DispatchQueue.sync means is: Make sure this task is eventually executed on thread X. Please warn me when you do so, because I will also block myself (the calling thread) until this task finishes running.

Given that, what do you think will be the output of the following actionOne()?

@IBAction func actionOne(_ sender: Any) { DispatchQueue.main.sync { [unowned self] in print("a") } print("b") }

A sync task is forwarded to the queue, and the main thread will freeze until "a" gets printed. The task gets sent to the Main Thread, which is frozen because it's waiting for the task to run. But the task can't run, because the thread is frozen waiting for the task to run, and on and on and on until your app decides to crash. You can't call sync dispatches from the thread itself, it has to come from somewhere else. Nothing will get printed here. As you most likely know, this is called a deadlock.

What else?

Hopefully, this is to answer the questions at the beginning of the article. Concurrency is a very wide issue, and in iOS, DispatchQueues are just one way to approach concurrency problems. There's still much more in the shape of Atomicity, OperationQueues, Locks, Semaphores. However, DispatchQueues are the most used concurrency tools in iOS, and when understood, one of the keys to writing efficient multi-threaded code.

Follow me on my Twitter - @rockbruno_, and let me know of any suggestions and corrections you want to share.

References and Good reads