paint-brush
Grand Central Dispatch, Once and for All by@kfamyn
New Story

Grand Central Dispatch, Once and for All

by Kiryl Famin20mFebruary 28th, 2025
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

This article breaks down Swift's Grand Central Dispatch (GCD) by explaining threads, queues, and code blocks, along with sync vs. async execution, QoS, and deadlock issues—using practical exercises to solidify your understanding.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Grand Central Dispatch, Once and for All
Kiryl Famin HackerNoon profile picture
0-item
1-item

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.

Content Overview

  • Basic Concepts: thread, multithreading, GCD, task, queue
  • Types of queues: main, global, custom
  • Queue priorities: Quality of Service (QoS)
  • Serial and concurrent queues
  • Ways to execute tasks: async, sync
  • Deadlock
  • GCD exercises
  • Links

Basic Concepts: Thread, Multithreading, GCD, Task, and Queue

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.

Threads in an application


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.


Queue


You can find mp4 versions of all GIFs here or in the “Links“ section below.

Types of queues

  1. Main queue – a queue that executes only on the main thread. It is serial (more on that later).

    let mainQueue = DispatchQueue.main
    


  2. Global queues – there are 5 queues (one for each priority level) provided by the system. They are concurrent.

    let globalQueue = DispatchQueue.global()
    


  3. 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).
    

Queue priorities

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:


  1. .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.


  2. .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).


  3. .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).


  4. .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.


  5. .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.

    Serial and concurrent queues


How to execute tasks

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.

Asynchronously (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()


  1. 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().


  2. 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.


  3. The task containing updateInterface() is enqueued asynchronously to the main queue—the calling worker thread doesn’t wait for its completion and continues.


  4. 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.



Asynchronous call


Note that in this animation a global queue executes its tasks on some free worker thread

Synchronously (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.



Synchronous task


Note: In the animation above, a custom queue executes its tasks on some free worker thread

Deadlock

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”)
}

Deadlock


Notice that print("B") from the main queue cannot execute because the main thread is blocked.

GCD Exercises

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.

Basic exercises

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”)


  1. On the main thread, print("A") is executed.
  2. The task to print("B") is enqueued asynchronously on the main queue. Since the main thread is busy, this task waits in the queue.
  3. On the main thread, print("C") is executed.
  4. When the main thread is free (after the previous task is completed, there might be other events that need processing on the main thread—not just the tasks from the main queue such as UI updates, gesture handling, etc. For a more in-depth understanding, please read more about RunLoop) the enqueued task print("B") is executed.


Answer: ACB


Task 2

print(“A”)
DispatchQueue.main.async {
  print(“B”)
 }
DispatchQueue.main.async {
  print(“C”)
}
print(“D”)


  1. On the main thread, print("A") is executed.
  2. The task to print("B") is enqueued on the main queue. The main queue until the main thread becomes available.
  3. The task to print("C") is enqueued after print("B") and also waits.
  4. The main thread continues executing and prints "D".
  5. When the main thread becomes available (after handling other RunLoop tasks), the first queued operation—print("B")—is executed.
  6. After the main thread becomes free again (after handling other RunLoop tasks—in the future, I will omit this detail as it doesn't affect the overall order), the task to 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”)
}


  1. print("A") is executed on the main thread.
  2. An asynchronous task (labeled 1–3) is enqueued on the main queue without blocking the current (main) thread.
  3. The main thread continues executing and prints "F".
  4. The print("G") operation is enqueued on the main queue after the previous task (steps 1–3).
  5. Once the main thread becomes free, the first queued operation—print("B")—begins execution.
  6. The 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.
  7. Next, the 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.
  8. Finally, the 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.
  • If the call were asynchronous, the print("E") operation would simply be enqueued after the operations for printing "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

  1. 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).

  2. When the system allocates resources, execution begins and "A" is printed.
  3. Within the same serial queue, a synchronous task to print("B") is enqueued. Because the call is synchronous, the thread blocks waiting for its execution.
  4. However, since the queue is serial and still busy with the outer task 1-2, the 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


  1. A task (steps 1–2) is enqueued asynchronously on the global (concurrent) queue.
  2. When resources are allocated, execution begins and "A" is printed.
  3. A synchronous call to execute print("B") on the same global queue is made, which blocks the current worker thread until the task completes.
  4. In this case, even though the thread is blocked, since the global queue is concurrent, it can begin executing the next operation without waiting for the current one to finish—simply by running it on another thread. Thus, the calling thread waits for the print("B") task to be executed on another worker thread.
  5. After the task is completed, the initial calling thread is unblocked, "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")


  1. The main thread prints "A".

  2. An asynchronous task (steps 1–8) is enqueued on the main queue without blocking the current thread.

  3. The main thread continues and prints "I".

  4. Later, when the main thread is free, the enqueued to the main queue task begins execution and prints "B".

  5. Another asynchronous task (steps 2–5) is enqueued on the main queue – not blocking current thread.

  6. 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.

  7. Operation 6–7 starts executing on another thread, printing "F".

  8. The operation to print("G") is synchronously dispatched to the global queue, blocking the current worker thread until it completes.

  9. "G" is printed, and the worker thread from which this operation was dispatched is unblocked.

  10. Operation 6–7 completes, unblocking the thread from which it was dispatched (the main thread), and "H" is printed.

  11. 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".

  12. Operation 3–4 is enqueued on the main queue without blocking the thread.

  13. Once the current operation (2–5) finishes, execution starts on the next operation (3–4), printing "D".

  14. The operation to print("G") is synchronously dispatched to the main queue, blocking the current thread.

  15. 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

Intermediate exercises

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")
}


  1. print("A") is enqueued asynchronously on the global queue—without blocking the current thread.
  2. We wait for the system to allocate resources for the task in the global queue. In theory, this could happen at any moment—even before executing the next command to enqueue 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.
  3. print("B") is enqueued on the global queue.
  4. Meanwhile, the main thread continues while the global queue waits for resource allocation.
  5. When resources become available, both tasks execute. Although the task printing "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

Advanced tasks

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.

Conclusion

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.

  1. YouTube channel with all animations - https://www.youtube.com/@kirylfamin
  2. Full code of exercises - https://github.com/kfamyn/GCD-Tasks
  3. My Telegram - http://t.me/kfamyn
  4. RunLoop - https://developer.apple.com/documentation/foundation/runloop
  5. sync method documentation - https://developer.apple.com/documentation/dispatch/dispatchqueue/sync(execute:)-3segw