Concurrency on iOS is a massive topic, so in this series, I want to zoom in on a sub-topic concerning queues and the Grand Central Dispatch (GCD) framework. Namely, I wish to explore the differences between serial and concurrent queues, as well as the differences between synchronous and asynchronous execution.
If you’ve never used GCD before, this series is a great place to start. If you have some experience with GCD, but are still curious about the topics mentioned above, I think you will still find it useful, and I hope you will pick up one or two new things along the way.
I created a SwiftUI companion app to visually demonstrate the concepts in this series. The app also has a fun short quiz that I encourage you to try before and after reading this series. Download the source code here, or get the public beta here.
I will begin with an introduction to GCD, followed by a detailed explanation on sync vs async. In Part 2, I will discuss serial and concurrent queues. And Part 3 will cover pitfalls that one might encounter with concurrency, before ending with a summary and some general advice.
Let’s start with a brief intro on GCD and dispatch queues. Feel free to skip to the Sync vs Async section if you are already familiar with the topic.
Concurrency and Grand Central Dispatch
Concurrency lets you take advantage of the fact that your device has multiple CPU cores. To make use of these cores, you will need to use multiple threads. However, threads are a low-level tool, and managing threads manually in an efficient manner is extremely difficult.
Grand Central Dispatch was created by Apple over 10 years ago as an abstraction to help developers write multi-threaded code without manually creating and managing the threads themselves.
With GCD, Apple took an asynchronous design approach to the problem. Instead of creating threads directly, you use GCD to schedule work tasks, and the system will perform these tasks for you by making the best use of its resources. GCD will handle creating the requisite threads and will schedule your tasks on those threads, shifting the burden of thread management from the developer to the system.
A big advantage of GCD is that you don’t have to worry about hardware resources as you write your concurrent code. GCD manages a thread pool for you, and it will scale from a single-core Apple Watch all the way up to a many-core Mac Pro.
These are the main building blocks of GCD, letting you execute arbitrary blocks of code using a set of parameters that you define. The tasks in dispatch queues are always started in a first-in, first-out (FIFO) fashion. Note that I said started, because the completion time of your tasks depends on several factors, and is not guaranteed to be FIFO (more on that later.)
Broadly speaking, there are three kinds of queues available to you:
- The Main dispatch queue (serial, pre-defined)
- Global queues (concurrent, pre-defined)
- Private queues (can be serial or concurrent, you create them)
Every app comes with a Main queue, which is a serial queue that executes tasks on the main thread. This queue is responsible for drawing your application’s UI and responding to user interactions (touch, scroll, pan, etc.) If you block this queue for too long, your iOS app will appear to freeze, and your macOS app will display the infamous beach ball/spinning wheel.
When performing a long-running task (network call, computationally intensive work, etc), we avoid freezing the UI by performing this work on a background queue, then we update the UI with the results on the main queue:
As a rule of thumb, all UI work must be executed on the Main queue. You can turn on the Main Thread Checker option in Xcode to receive warnings whenever UI work gets executed on a background thread.
In addition to the main queue, every app comes with several pre-defined concurrent queues that have varying levels of Quality of Service (an abstract notion of priority in GCD.)
For example, here’s the code to submit work asynchronously to the user interactive (highest priority) QoS queue:
Alternatively, you can call the default priority global queue by not specifying a QoS like this:
Additionally, you can create your own private queues using the following syntax:
When creating private queues, it helps to use a descriptive label (such as reverse DNS notation), as this will aid you while debugging in Xcode’s navigator, lldb, and Instruments:
By default, private queues are serial (I’ll explain what this means shortly, promise!) If you want to create a private concurrent queue, you can do so via the optional attributes parameter:
There is an optional QoS parameter as well. The private queues that you create will ultimately land in one of the global concurrent queues based on their given parameters.
What’s in a task?
I mentioned dispatching tasks to queues. Tasks can refer to any block of code that you submit to a queue using the
async functions. They can be submitted in the form of an anonymous closure:
Or inside a dispatch work item that gets performed later:
Regardless of whether you dispatch synchronously or asynchronously, and whether you choose a serial or concurrent queue, all of the code inside a single task will execute line by line. Concurrency is only relevant when evaluating multiple tasks.
For example, if you have 3 loops inside the same task, these loops will always execute in order:
This code always prints out ten digits from 0 to 9, followed by ten blue circles, followed by ten broken hearts, regardless of how you dispatch that closure.
Individual tasks can also have their own QoS level as well (by default they use their queue’s priority.) This distinction between queue QoS and task QoS leads to some interesting behaviour that we will discuss in the priority inversion section.
By now you might be wondering what serial and concurrent are all about. You might also be wondering about the differences between
async when submitting your tasks. This brings us to the crux of this series, so let’s dive in!
Sync vs Async
When you dispatch a task to a queue, you can choose to do so synchronously or asynchronously using the
async dispatch functions. Sync and async primarily affect the source of the submitted task, i.e. the queue where it is being submitted from.
When your code reaches a
sync statement, it will block the current queue until that task completes. Once the task returns/completes, control is returned to the caller, and the code that follows the
sync task will continue.
sync as synonymous with ‘blocking’.
async statement, on the other hand, will execute asynchronously with respect to the current queue, and immediately returns control back to the caller without waiting for the contents of the
async closure to execute. There is no guarantee as to when exactly the code inside that async closure will execute.
It may not be obvious what the source, or current, queue is, because it’s not always explicitly defined in the code. For example, if you call your
sync statement inside viewDidLoad, your current queue will be the Main dispatch queue. If you call the same function inside a URLSession completion handler, your current queue will be a background queue.
Going back to sync vs async, let’s take this example:
The above code will block the current queue, enter the closure and execute its code on the global queue by printing “Inside”, before proceeding to print “Outside”. This order is guaranteed.
Let’s see what happens if we try
Our code now submits the closure to the global queue, then immediately proceeds to run the next line. It will likely print “Outside” before “Inside”, but this order isn’t guaranteed, depending on the QoS of the source and destination queues, as well as other factors that the system controls.
Threads are an implementation detail in GCD — we do not have direct control over them and can only deal with them using queue abstractions. Nevertheless, I think it can be useful to ‘peek under the covers’ at thread behaviour to understand some challenges we might encounter with GCD.
For instance, when you submit a task using
sync, GCD optimizes performance by executing that task on the current thread (the caller.) There is one exception however, which is when you submit a sync task to the main queue — doing so will always run the task on the main thread and not the caller. This behaviour can have some ramifications that we will explore in the priority inversion section.
Which one to use?
When submitting work to a queue, Apple recommend using asynchronous execution over synchronous execution. However, there are situations where
sync might be the better choice, such as when dealing with race conditions, or when performing a very small task. I will cover these situations shortly.
One large consequence of performing work asynchronously inside a function is that the function can no longer directly return its values (if they depend on the async work that’s being done), and must instead use a closure/completion handler parameter to deliver the results.
To demonstrate this concept, let’s take a small function that accepts image data, performs some expensive computation to process the image, then returns the result:
In this example, the function
upscaleAndFilter(image:) might take several seconds, so we want to offload it into a separate queue to avoid freezing the UI. Let’s create a dedicated queue for image processing, and then dispatch the expensive function asynchronously:
There are two issues with this code. First, the return statement is inside the async closure, so it is no longer returning a value to the
processImageAsync(data:) function, and currently serves no purpose. But the bigger issue is that our
processImageAsync(data:) function is no longer returning any value, because the function reaches the end of its body before it enters the
To fix this error, we will adjust the function so that it no longer directly returns a value. Instead, it will have a new completion handler parameter that we can call once our asynchronous function has completed its work:
As evident in this example, the change to make the function asynchronous has propagated to its caller, who now has to pass in a closure and handle the results asynchronously as well. By introducing an asynchronous task, you can potentially end up modifying a chain of several functions.
Concurrency and asynchronous execution add complexity to your project as we just observed. This indirection also makes debugging more difficult. That’s why it really pays off to think about concurrency early in your design — it’s not something you want to tack on at the end of your design cycle.
Synchronous execution, by contrast, does not increase complexity; it allows you to continue using return statements as you did before. A function containing a
sync task will not return until the code inside that task has completed. Therefore it does not require a completion handler.
If you are submitting a tiny task (e.g. updating a value), consider doing it synchronously. Not only does that help you keep your code simple, it will also perform better — Async is believed to incur an overhead that outweighs the benefit of doing the work asynchronously for tiny tasks that take under 1ms to complete.
If you are submitting a large task, however, like the image processing we performed above, then consider doing it asynchronously, to avoid blocking the caller for too long.
Dispatching on the same queue
While it is safe to dispatch a task asynchronously from a queue into itself (e.g. you can use .asyncAfter on the current queue), you can not dispatch a task synchronously from a queue into the same queue. Doing so will result in a deadlock and immediately crashes the app!
This issue can manifest itself when performing a chain of synchronous calls that lead back to the original queue, i.e. you
sync a task onto another queue, and when the task completes, it syncs the results back into the original queue, leading to a deadlock. Use
async to avoid such crashes.
Blocking the main queue
Dispatching tasks synchronously from the main queue will block that queue, thereby freezing the UI, until the task is completed. Thus it’s better to avoid dispatching work synchronously from the main queue, unless you’re performing really light work.
— End of Part 1 —
Check out Part 2 here:
Concurrency Visualized - Part 2: Serial vs Concurrent
Intro. "Concurrency Visualized - Part 2: Serial vs Concurrent" is published by Besher Al Maleh.
If you have any questions or comments, feel free to reach out to me on Twitter