
Hello! My name is Kiryl Famin, and I am an iOS developer.
In this article, we will cover Grand Central Dispatch (GCD) once and for all. Although GCD might seem outdated now that Swift Modern Concurrency exists, code using this framework will continue to appear for many years—both in production and in interviews.
Today we will focus solely on the fundamental understanding of GCD. We will only examine key aspects of multithreading in detail, including the relationship between queues and threads—a topic many other articles tend to overlook. With these concepts in mind, it’ll be easier for you to understand topics such as DispatchGroup
, DispatchBarrier
, semaphore, mutex, and so on.
This article will be useful for both beginners and experienced developers. I will try to explain everything in clear language, avoiding an overload of technical terms.
Thread – essentially, a container where a set of system instructions is placed and executed. In fact, all executable code runs on some thread. We distinguish between the main thread and worker threads.
Multithreading – the ability of a system to execute several threads concurrently (at the same time). This allows multiple branches of code to run in parallel.
Grand Central Dispatch (GCD) – a framework that facilitates working with threads (leveraging the benefits of multithreading). Its main primitives are tasks and queues.
Thus, GCD is a tool that makes it easy to write code that executes concurrently. A simple example is offloading heavy computations to a separate thread so as not to interfere with UI updates on the main thread.
Task – a set of instructions grouped together by the developer. It’s important to understand that the developer decides which code belongs to a particular task.
For example:
print(“GCD”) // a task
let database = Database()
let person = Person(age: 23) // also a task
database.store(person)
Queue – the fundamental primitive of GCD, it is the place where the developer puts tasks for execution. The queue takes on the responsibility of distributing tasks among threads (each queue has access to the system’s thread pool).
Essentially, queues allow you to focus on organizing your code into tasks rather than managing threads directly. When you dispatch a task onto a queue, it will be executed on an available thread — often different from the one used to dispatch the task.
You can find mp4 versions of all GIFs
Main queue – a queue that executes only on the main thread. It is serial (more on that later).
let mainQueue = DispatchQueue.main
Global queues – there are 5 queues (one for each priority level) provided by the system. They are concurrent.
let globalQueue = DispatchQueue.global()
Custom queues – queues created by the developer. The developer chooses one of the 5 priorities and the type: serial or concurrent (by default, they are serial).
let userQueue = DispatchQueue(label: “com.kirylfamin.concurrent”, attributes: .concurrent).
Quality of Service (QoS) – a system for queue priorities. The higher the priority of the queue in which a task is enqueued, the more resources are allocated to it. There are 5 QoS levels in total:
.userInteractive
– the highest priority. It is used for tasks that require immediate execution but aren’t suitable to run on the main thread. For example, in an app that allows real-time image retouching, the retouch result must be calculated instantly; however, if done on the main thread, it would interfere with UI updates and gesture handling which always occur on the main thread (e.g., when the user slides their finger over the area to be retouched, and the app must instantly display the result “under the finger”). Thus, we get the result as fast as possible without burdening the main thread.
.userInitiated
– a priority for tasks that require fast feedback, though not as critical as interactive tasks. It is typically used for tasks where the user understands that the task won’t complete instantly and will have to wait (for example, a server request).
.default
– the standard priority. It is assigned if the developer does not specify a QoS when creating a queue – when there are no specific requirements for the task and its priority can’t be determined from context (for example, if you invoke a task from a queue with a .userInitiated priority, the task inherits that priority).
.utility
– a priority for tasks that do not require immediate user feedback but are necessary for the app’s operation. For example, synchronizing data with a server or writing an autosave to disk.
.background
– the lowest priority. An example is cache cleaning.
All queues are classified as Serial Queues or Concurrent Queues
Serial queues – as the name implies, these are queues where tasks execute one after the other. This means that the next task starts only after the current one finishes.
Concurrent queues – These queues allow tasks to execute in parallel – a new task starts as soon as resources are allocated, regardless of whether previous tasks have completed. Note that only the start order is guaranteed (a task enqueued earlier will begin before a later one), but the order of completion is not guaranteed.
It is important to note that we are now discussing the execution methods of tasks relative to the calling thread. In other words, the manner in which you call a task determines how events unfold in the thread from which you dispatch the task to a queue.
async
)An asynchronous call is one where the calling thread is not blocked — that is, it does not wait for the task it enqueues to execute.
DispatchQueue.main.async {
print(“A”)
}
print(“B”)
In this example, we asynchronously enqueue the task to print("A")
on the main queue from the main thread (since this code is not inside any specific queue, it is executed on the main thread by default). Thus, we do not wait for the task on the main thread and continue execution immediately. In this particular example, the print("A")
task is enqueued on the main queue and then print("B")
is executed immediately on the main thread. Because the main thread is busy executing the current code (and tasks from the main queue can only execute on the main thread), the current task print("B")
finishes first, and only after the main thread is free does the enqueued on the main queue task print("A")
run. The output is: BA.
DispatchQueue.global().async {
updateData()
DispatchQueue.main.async {
updateInterface()
}
Logger.log(.success)
}
indicateLoading()
We asynchronously add a task to the global queue with default priority from the main thread — so the calling thread immediately continues and calls indicateLoading()
.
After some time, the system allocates resources for the task and executes it on a free worker thread from the thread pool, and updateData()
is called.
The task containing updateInterface()
is enqueued asynchronously to the main queue—the calling worker thread doesn’t wait for its completion and continues.
Because tasks are enqueued asynchronously, we cannot be certain when resources will be allocated. In this case, we can’t say for sure whether updateInterface()
(on the main thread) or Logger.log(.success)
(on a worker thread) will execute first (neither we can in steps 1-2: which executes first, indicateLoading()
on the main thread or updateData()
on a worker thread). Although the main thread is busy handling UI updates, gesture processing, and other underlying tasks, nevertheless it always receives the maximum system resources. On the other hand, resources for execution on a worker thread can be allocated almost immediately too.
Note that in this animation a global queue executes its tasks on some free worker thread
sync
)A synchronous call is one where the calling thread stops and waits for the task it has enqueued on a queue to complete.
let userQueue = DispatchQueue(label: "com.kirylfamin.serial")
DispatchQueue.global().async {
var account = BankAccount()
userQueue.sync {
account.balance += 10
}
let balance = account.balance
print(balance)
}
Here, from a worker thread executing a task on the global queue, we synchronously enqueue a task on a custom queue to increase the balance. The current thread is blocked and waits for the enqueued task to finish. Thus, the balance is printed only after the task on the custom queue completes the increment.
Note: In the animation above, a custom queue executes its tasks on some free worker thread
In the context of synchronous tasks, it is important to discuss deadlock—when a thread or threads wait indefinitely for themselves or each other to proceed. The most common example is calling DispatchQueue.main.sync {} from the main thread.
The main thread is busy executing the current task, within which we want to synchronously execute some code. Thus, the synchronous call blocks the main thread. The task is enqueued on the main queue but cannot start because the main thread is blocked waiting for the current task to finish—and tasks on the main queue can only run on the main thread. This may be hard to visualize at first, but the key is to understand that the task enqueued with DispatchQueue.main.sync
becomes part of the current task, and we are queuing it after the current task. As a result, the thread waits for a part of the current task that cannot start because the thread is occupied by the current task.
func printing() {
print(“A”)
DispatchQueue.main.sync {
print(“B”)
}
print(“C”)
}
Notice that print("B")
from the main queue cannot execute because the main thread is blocked.
In this section, with all the knowledge acquired so far, we will discuss exercises of different complexities: from simple code blocks you will encounter in interviews to advanced challenges that push your understanding of concurrent programming. The question in all these tasks is: What will be printed to the console?
Remember that the main queue is serial, global() queues are concurrent, and sometimes the problem may include custom queues with specific attributes.
We will start with tasks of normal difficulty – those with little chance of uncertainty in output. These tasks are the ones most likely to appear in interviews; the key is to take your time and carefully analyze the problem.
You can find full code of all exercises here.
Task 1
print(“A”)
DispatchQueue.main.async {
print(“B”)
}
print(“C”)
print("A")
is executed.print("B")
is enqueued asynchronously on the main queue. Since the main thread is busy, this task waits in the queue.print("C")
is executed.print("B")
is executed.
Answer: ACB
Task 2
print(“A”)
DispatchQueue.main.async {
print(“B”)
}
DispatchQueue.main.async {
print(“C”)
}
print(“D”)
print("A")
is executed.print("B")
is enqueued on the main queue. The main queue until the main thread becomes available.print("C")
is enqueued after print("B") and also waits.print("B")
—is executed.print("C")
is executed.
Answer: ADBC
I should mention right away that in some examples, I will simplify the explanation a bit and omit the fact that system optimizes the execution of synchronous calls, which we will discuss later.
Task 3
print(“A”)
DispatchQueue.main.async { // 1
print(“B”)
DispatchQueue.main.async {
print(“C”)
}
DispatchQueue.global().sync {
print(“D”)
}
DispatchQueue.main.sync { // 2
print(“E”)
}
} // 3
print(“F”)
DispatchQueue.main.async {
print(“G”)
}
print("A")
is executed on the main thread."F"
.print("G")
operation is enqueued on the main queue after the previous task (steps 1–3).print("B")
—begins execution.print("C")
operation is then enqueued on the main queue (where the current task is still executing, and print("G")
follows it in the queue). Since it is added asynchronously, we do not wait for its execution and move on immediately.print("D")
operation is enqueued on the global queue. Because this call is synchronous, we wait until the global queue executes it (it may run on any available worker thread) before proceeding.print("E")
operation is enqueued on the main queue. Since this call is synchronous, the current thread must be blocked until the task completes. However, there are already tasks in the main queue, and the print("E")
operation is added to the end, after them. Therefore, those operations must execute first before print("E")
can run. But the main thread is still busy executing the current operation, so it cannot move on to the next queued operations. Even if there were no operations for printing "G"
and "C"
after the current operation, the thread still couldn’t proceed because the current operation (steps 1–3) has not yet completed."G"
and "C"
.
Answer: AFBD
Alternate answer (if the second call were async
): AFBDGCE
Task 4
let serialQueue = DispatchQueue(label: “com.kirylfamin.serial”)
serialQueue.async { // 1
print(“A”)
serialQueue.sync {
print(“B”)
}
print(“C”)
} // 2
A task (steps 1–2) is enqueued asynchronously on a custom serial queue (by default, queues are serial since we didn’t use the .concurrent
attribute).
"A"
is printed.print("B")
is enqueued. Because the call is synchronous, the thread blocks waiting for its execution.print("B")
task cannot start, resulting in a deadlock.
Answer: A, deadlock
This example shows that deadlock can occur on any serial queue—whether it’s the main queue or a custom one.
Task 5
Let’s replace the serial queue from the previous task with a concurrent one.
DispatchQueue.global().async { // 1
print("A")
DispatchQueue.global().sync {
print("B")
}
print("C")
} // 2
"A"
is printed.print("B")
on the same global queue is made, which blocks the current worker thread until the task completes.print("B")
task to be executed on another worker thread."C"
is printed.Answer: ABC
Task 6
print("A")
DispatchQueue.main.async { // 1
print("B")
DispatchQueue.main.async { // 2
print("C")
DispatchQueue.main.async { // 3
print("D")
DispatchQueue.main.sync {
print("E")
}
} // 4
} // 5
DispatchQueue.global().sync { // 6
print("F")
DispatchQueue.global().sync {
print("G")
}
} // 7
print("H")
} // 8
print("I")
The main thread prints "A"
.
An asynchronous task (steps 1–8) is enqueued on the main queue without blocking the current thread.
The main thread continues and prints "I"
.
Later, when the main thread is free, the enqueued to the main queue task begins execution and prints "B"
.
Another asynchronous task (steps 2–5) is enqueued on the main queue – not blocking current thread.
Continuing execution on the current thread, a synchronous dispatch of operation 6–7 is made to the global queue—this blocks the current (main) thread until the task completes.
Operation 6–7 starts executing on another thread, printing "F"
.
The operation to print("G")
is synchronously dispatched to the global queue, blocking the current worker thread until it completes.
"G"
is printed, and the worker thread from which this operation was dispatched is unblocked.
Operation 6–7 completes, unblocking the thread from which it was dispatched (the main thread), and "H"
is printed.
After the completion of operation 1–2, execution moves to the next operation in the main queue—operation 2–5—which begins and prints "C"
.
Operation 3–4 is enqueued on the main queue without blocking the thread.
Once the current operation (2–5) finishes, execution starts on the next operation (3–4), printing "D"
.
The operation to print("G")
is synchronously dispatched to the main queue, blocking the current thread.
The system then waits indefinitely for the operation to print("E")
to execute on the main thread—since the thread is blocked, this leads to a deadlock.
Answer: AIBFGHCD, deadlock
Tasks of intermediate difficulty involve uncertainty. Such problems are also encountered in interviews, though rarely.
Task 7
DispatchQueue.global().async {
print("A")
}
DispatchQueue.global().async {
print("B")
}
print("A")
is enqueued asynchronously on the global queue—without blocking the current thread.print("B")
. In this particular case, the next task is added to the queue first, and only then are resources allocated to the global queue. This happens because the main thread is allocated the most resources, and the next operation on the main thread is very lightweight (merely the operation of adding a task), and in practice it occurs faster than the resource allocation in the global queue. We will discuss the opposite scenarios in the next section.print("B")
is enqueued on the global queue."A"
might start earlier than "B"
, we cannot guarantee the order because print is not an atomic operation (the moment the output appears in the console is near the end of the operation).
Answer: (AB)
The parentheses indicate that the letters may appear in any order: either AB or BA.
Task 8
print("A")
DispatchQueue.main.async {
print("B")
}
DispatchQueue.global().async {
print("C")
}
Here, we can only be sure that "A" is printed first. We cannot precisely determine whether the task on the main queue or the one on the global queue will execute faster.
Answer: A(BC)
Task 9
DispatchQueue.global(qos: .userInteractive).async {
print(“A”)
}
DispatchQueue.main.async { // 1
print(“B”)
}
and
DispatchQueue.global(qos: .userInteractive).async {
print(“A”)
}
print(“B”) // 1
On one hand, in both cases print("B")
is executed on the main thread. Also, we cannot determine exactly when the global queue will be allocated resources, so theoretically, "A"
might be printed immediately before reaching the point marked // 1 on the main thread. In practice, however, the first task always prints as AB, while the second prints as BA. This is because in the first case, print("B")
is executed at least in the next RunLoop iteration of the main thread (or a few iterations later), whereas in the second case, print("B")
is scheduled to run in the current RunLoop iteration on the main thread. However, we can’t guarantee the order.
Answer for both tasks: (AB)
Task 10
print("A")
DispatchQueue.global().async {
print("B")
DispatchQueue.global().async {
print("C")
}
print("D")
}
It is clear that the beginning of the output is "AB"
. After enqueuing print("C")
, we cannot determine exactly when resources will be allocated for it—this task might execute either before or after print("D")
. This sometimes happens in practice as well.
Answer: AB(CD)
Task 11
let serialQueue = DispatchQueue(label: “com.kirylfamin.serial”, qos: .userInteractive)
DispatchQueue.main.async {
print(“A”)
serialQueue.async {
print(“B”)
}
print(“C”)
}
Again, we cannot determine precisely when resources will be allocated for print("B") on the custom queue. In practice, since the main thread is given the highest priority, "C" usually prints before "B", though this is not guaranteed.
Answer: A(BC)
Task 12
DispatchQueue.global().async {
print("A")
}
print("B")
sleep(1)
print("C")
Here, it is obvious that the output will be BAC because the one-second sleep ensures that the global queue has enough time to allocate resources. While the main thread is blocked by sleep (which you should not do in production), print("A")
executes on another thread.
Answer: BAC
Task 13
DispatchQueue.main.async {
print("A")
}
print("B")
sleep(1)
print("C")
In this case, since print("A")
is enqueued on the main queue, it can only be executed on the main thread. However, the main thread continues executing the code — printing "B"
, then sleeping, then printing "C"
. Only after that can the RunLoop execute the enqueued task.
Answer: BCA
You're unlikely to encounter these problems in interviews, but understanding them will help you better grasp GCD.
The Counter class here is used solely for reference semantics:
final class Counter {
var count = 0
}
Task 14
let counter = Counter()
DispatchQueue.global().async {
DispatchQueue.main.async {
print(counter.count)
}
for _ in (0..<100) { // 1
counter.count += 1
}
}
Here, any number between 0 and 100 may be printed, depending on how busy the main thread is. As we know, we cannot predict exactly when the asynchronous task will get resources—it may happen before, during, or after the loop on worker thread.
Answer: 0-100
Task 15
DispatchQueue.global(qos: .userInitiated).async {
print(“A”)
}
DispatchQueue.global(qos: .userInteractive).async {
print(“B”)
}
QoS does not guarantee that the queue with a higher priority will receive resources faster, although iOS will try to do so. In practice, the output here is (AB).
Answer: (AB)
Task 16
var count = 0
DispatchQueue.global(qos: . userInitiated).async {
for _ in 0..<1000 {
count += 1
}
print(“A”)
}
DispatchQueue.global(qos: .userInteractive).async {
for _ in 0..<1000 {
count += 1
}
print(“B”)
}
Since we cannot know which execution starts first, even across 1000 operations we cannot determine which task will complete faster.
Answer: (AB)
Task 16.2
What is the output assuming the operations start executing simultaneously?
Since the .userInteractive queue is allocated more resources, over the span of 1000 operations the execution in that queue will always finish faster.
Answer: BA
Task 17
Using a similar approach, we can modify any task with uncertainty from the previous section (for example, Task 12):
let counter = Counter()
let serialQueue = DispatchQueue(label: “com.kirylfamin.serial”, qos: .userInteractive)
DispatchQueue.main.async {
serialQueue.async {
print(counter.count)
}
for _ in 0..<100 {
counter.count += 1
}
}
Any number between 0 and 100 may be printed. The fact that 0 can be printed confirms that in Task 12 we cannot guarantee that the output of "C"
will always occur before "B"
, as essentially nothing has changed—only that the loop is slightly more resource-intensive than a print (note that simply starting the loop, even before its execution, has in practice resulted in complete uncertainty).
Answer: 0-100
Task 18
DispatchQueue.global(qos: .userInitiated).async {
print(“A”)
}
print(“B”)
DispatchQueue.global(qos: .userInteractive).async {
print(“C”)
}
A similar situation occurs here. In theory, print("A")
might execute faster than print("B")
(if you replace print("B")
with something slightly heavier). In practice, "B"
always prints first. However, the fact that we execute print("B")
before enqueuing print("C")
greatly increases the likelihood that "A"
will be printed before "C"
, since the extra time spent on print("B")
on the main thread is often sufficient for the .userInitiated queue to get resources and execute print("A")
. Nonetheless, this is not guaranteed and sometimes "C"
might print faster. Thus, in theory there is complete uncertainty; in practice, it tends to be B(CA).
Answer: (BCA)
Task 19
DispatchQueue.global().sync {
print(Thread.current)
}
The documentation for sync states:
“As a performance optimization, this function executes blocks on the current thread whenever possible, with one exception: blocks submitted to the main dispatch queue always run on the main thread.”
This means that for optimization purposes, synchronous calls may execute on the same thread from which they were called (with the exception of main.sync
– tasks using it always execute on the main thread). Thus, the current (main) thread is printed.
Answer: main thread
Task 20
DispatchQueue.global().sync { // 1
print(“A”)
DispatchQueue.main.sync {
print(“B”)
}
print(“C”)
}
Only "A"
is printed because a deadlock occurs. Due to optimization, the task (labeled 1) begins executing on the main thread, and then calling main.sync
leads to a deadlock.
Answer: А, deadlock
Task 21
DispatchQueue.main.async {
print("A")
DispatchQueue.global().sync {
print("B")
}
print("C")
}
Optimization causes the print("B")
task not to be enqueued but to be “spliced” into the current execution thread. Thus, the code:
DispatchQueue.global().sync {
print("B")
}
becomes equivalent to:
print(“B”)
Answer: ABC
From these tasks, it is clear that you must use main.sync very carefully—only when you are certain that the call is not made from the main thread.
In this article, we focused on the foundational concepts of multithreading in iOS—threads, tasks, and queues—and their interrelationships. We explored how GCD manages the execution of tasks across the main, global, and custom queues, and discussed the differences between serial and concurrent execution. Additionally, we examined the critical distinctions between synchronous (sync) and asynchronous (async) task dispatch, highlighting how these approaches affect the order and timing of code execution. Mastering these basic concepts is essential for building responsive, stable applications and for avoiding common pitfalls such as deadlocks.
I hope you found something useful in this article. If anything remains unclear, feel free to contact me for a free explanation on Telegram: @kfamyn.
sync
method documentation - https://developer.apple.com/documentation/dispatch/dispatchqueue/sync(execute:)-3segw