Grand Central Dispatch

11 minute read

Overview

  • GCD helps to write multi-threaded code, create threads and schedule tasks on those threads.
  • Main building block: Dispatch Queues, 3 types of queues:
    • Main dispatch queue (serial, pre-defined)
      • drawing app’s UI 🎨
      • handle events (e.g. user interaction)
      • block it for too long, app will freeze πŸ₯Ά
    • Global queue (concurrent, pre-defined)
    • Private queue (can be serial/concurrent optionally, serial by default)

Usage:

  • Making a network call, which takes a long time in background queue
  • When it completes, updating UI on main thread.
1URLSession.shared.dataTask(with: url) { data, response, error in
2    if let data = data {
3        DispatchQueue.main.async { 
4            self.label.text = String(data: data, encoding: .utf8)
5        }
6    }
7}

Serial queue

  • a serial queue is created, and a task is dispatched to that queue, system creates one thread for it and it is independent with other serial queues.
  • For example, 2 serial queues are created, started at the same time, will start running simultaneously.
 1let serial1 = DispatchQueue(label: "tinhpv.serial1")
 2let serial2 = DispatchQueue(label: "tinhpv.serial2")
 3
 4serial1.async {
 5    for _ in 0..<5 { print("πŸ”΅") }
 6}
 7
 8serial2.async {
 9    for _ in 0..<5 { print("πŸ”΄") }
10}
11
12/// CONSOLE
13/// Both queues here are serial, but the results are jumbled up because they execute concurrently in relation to each other
14/// Their QoS level determines who will generally finish first (order not guaranteed)
15πŸ”΅
16πŸ”΄
17πŸ”΄
18πŸ”΄
19πŸ”΅
20πŸ”΄
21πŸ”΅
22πŸ”΄
23πŸ”΅
24πŸ”΅
  • want the first queue to be done first then the next queue execute their task? β†’ use sync, but it will block the caller.
  • another way? β†’ use one single queue for 2 tasks.
 1serial1.async {
 2    for _ in 0..<5 { print("πŸ”΅") }
 3}
 4
 5serial1.async {
 6    for _ in 0..<5 { print("πŸ”΄") }
 7}
 8
 9/// CONSOLE
10πŸ”΅
11πŸ”΅
12πŸ”΅
13πŸ”΅
14πŸ”΅
15πŸ”΄
16πŸ”΄
17πŸ”΄
18πŸ”΄
19πŸ”΄

sync/async vs. concurrent/serial

sync/async and concurrent/serial are two SEPARATE concepts.

  • Synchronous vs. asynchronous is about when THE CALLER (the queue) can continue.
    • sync block the current queue until that task completes. Once the current task is completes/returns, another task can be dispatched to the queue.
    • async execute asynchronously with respect to the current queue. Another task can be dispatched to the queue (BUT that task can be run or not depends on the queue is serial or concurrent)

β–Ί 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.

  • Concurrent vs. serial is about when the DISPATCHED TASK of the queue can run.
    • serial
      • the task maybe cannot run immediately if the queue is already running some other tasks.
      • NO more than 1 thread at a time.
      • Guarantee order
    • concurrent
      • multiple threads, system decides number of threads.
      • Do not wait each other, order is not guaranteed.

Example:

1let queue = DispatchQueue(label: "queue_label")
2queue.sync { // (1) submit a task synchronously into the queue
3    queue.sync { // (2)
4        // dead lock ☠️
5    }
6}

☠️ why deadlock?

  • first task is executing, which submits another task to the queue SYNCHRONOUSLY.
  • “SYNCHRONOUSLY” = first task have to DONE so that the next task can be dispatched to the queue.
  • so, second task DOES NOT return because it’s waiting for the first task to be done.
  • also, this is a SERIAL queue, have to execute tasks in order (first task have to be done > second task could starts)
  • the first can’t finish because it’s waiting for the second to finish, but the second can’t finish because it’s waiting for the first to finish.

Another one:

1let queue = DispatchQueue(label: "queue_label")
2queue.async { // ← (1) change this to async
3    queue.sync { // (2)
4        // Still dead lock ☠️
5    }
6}

☠️ why still deadlock?

  • now first task is dispatched ASYNCHONOUS to the queue and the queue executes this task.
  • this task is dispatching another task to the queue but now it is SYNCHRONOUS.
  • the queue is now is BLOCKED by the sync statement until the second completes, but the second task cannot be started because the queue is serial, wait for the first task, but the first task is not returned because the second task (its content) is not returned either!

SOLUTION:

1let queue = DispatchQueue(label: "queue_label")
2queue.sync { // ← sync or async is still working
3    queue.async { // ← make it async
4        print("Inner!")
5    }
6}
  • the first task was submitted async or sync is still working.
  • in case the first task is sync, it blocks the queue until the content inside returned.
  • as per content of the first task, it’s dispatching asynchronously another task to the queue, that async statement can be returned immediately (that second task is successfully dispatched to the queue, but it is still not yet executed, because this queue is serial, have to wait for the first task to be done)
  • so now the first task could be done and it is time for the second task

Another example:

 1let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent)
 2
 3concurrentQueue.sync { // ← block the caller
 4    for _ in 0..<5 { print("πŸ”΅") }
 5}
 6
 7concurrentQueue.async { // ← try to dispatch a task to the queue while it executing the sync statement
 8    for _ in 0..<5 { print("πŸ”΄") }
 9}
10
11/// CONSOLE
12πŸ”΅
13πŸ”΅
14πŸ”΅
15πŸ”΅
16πŸ”΅
17πŸ”΄
18πŸ”΄
19πŸ”΄
20πŸ”΄
21πŸ”΄

Priority Inversion

  • a high priority task is prevented from running by a lower priority task
  • often occurs when a high QoS queue shares a resources with a low QoS queue, and the low QoS queue gets a lock on that resource

E.g:

  • scenario: submit a task to low-QoS serial queue β†’ submit another task that have high priority to that queue.
  • problem: high-priority task waits for low-priority task to be finished
  • resolve: temporarily increase the QoS of the queue of the low-priority task
 1let starterQueue = DispatchQueue(label: "starter.queue", qos: .userInteractive)
 2let utilityQueue = DispatchQueue(label: "utility.queue", qos: .utility)
 3let backgroundQueue = DispatchQueue(label: "background.queue", qos: .background)
 4
 5starterQueue.async {
 6    backgroundQueue.async { 
 7        output(color: .white)
 8    }
 9
10    utilityQueue.async {
11        output(color: .blue)
12    }
13
14    backgroundQueue.sync {} // this one make priority inversion
15}
16
17/// CONSOLE
18/// blue circles were printed last
19βšͺ️
20πŸ”΅
21βšͺ️
22βšͺ️
23βšͺ️
24πŸ”΅
25βšͺ️
26πŸ”΅
27πŸ”΅
28πŸ”΅
  • the last sync statement run on the caller thread (starterQueue with top priority), it blocks the queue now (1)
  • but another task that has been submited to the same queue but asynchronously (2)
  • (1) have to wait for (2) (cuz starter is a serial queue) while (1) has higher priority than (2)

β†’ GCD increases the QoS of the background queue to temporarily match the high QoS task (userInteractive) β†’ background queue is now having high priority than the other (utility)


Thread explosion

  • try to submit tasks to a concurrent queue that is currently blocked (e.g. with a semaphore, sync, or some other way.)
  • These tasks will run, but the system will likely end up spinning up new threads to accommodate these new tasks.
  • Apple suggests starting with a serial queue per subsystem in your app, as each serial queue can only use one thread at a time.

Serial queues are concurrent in relation to other queues, so you still get a performance benefit when you offload your work to a queue, even if it isn’t concurrent.


Race Condition, Solved By Dispatch Barrier

  • Readers-writers problem e.g: multiple threads access/modify the same resource (as an array, a file)

One of solutions: using an isolation queue use barrier flag - whether it’s allowed to run concurrently with any other dispatched blocks to that queue

  • submitting a task without barrier flag, the queue works as a normal concurrent queue
  • when the barrier is executing, it is working as a serial queue.
  • blocks any further tasks from executing until the barrier task is completed.
  • use barrier flag on a serial queue is redundant πŸ€·β€β™‚οΈ

A block submitted with this flag will act as a barrier:

  • All other blocks that were submitted BEFORE the barrier will finish and only then the barrier block will execute.
  • All blocks submitted AFTER the barrier will not start until the barrier has finished.

⛔️ Barriers do not work on global queues; they only affect private concurrent queues that you created ⛔️ Why?

β†’ Global queues are shared. You’re not the only one possibly availing yourself of these queues. Other subsystems in your app might be using them. The OS might, too. And barriers are a blocking operation, which could have serious impact if they started blocking unrelated systems. It seems exceeding prudent to me that GCD prevents one a bit of code in one subsystem from blocking all the other completely unrelated subsystems

 1let isolation = DispatchQueue(label: "queue.isolation", attributes: .concurrent)
 2private var _array = [1,2,3,4,5]
 3var threadSafeArray: [Int] {
 4       get {
 5            return isolation.sync { // ← need to wait the result, if `async`, it returns immediately
 6                _array
 7            }
 8        }
 9        set {
10            isolation.async(flags: .barrier) {
11                self._array = newValue
12            }
13        }
14}

While this code will definitely work, using async for writes can be counter-intuitively be less performant. async calls will create a new thread, if there isn’t one immediately available to run the block. If your write call is faster than 1ms, it might be worth making it .sync, according to Pierre Habouzit, who worked on this code at Apple. As always, profile before optimizing!

could use a serial queue without a barrier to solve the race condition but then we would lose the advantage of having concurrent read access to the array.


Dispatch Semaphore

  • control access to a shared resource by multiple threads
  • block a thread for an amount of time, until a single from another thread is posted.
  • thread-safe, can be triggered from anywhere
1// on a background queue
2let semaphore = DispatchSemaphore(value: 0)
3doSomeExpensiveWorkAsynchronously(completionBlock: {
4    semaphore.signal()
5})
6semaphore.wait()
7//the expensive asynchronous work is now done
  • value, counter, the number of available resources

  • wait() β†’ asking for accessing shared resource πŸ™‹πŸΌβ€β™‚οΈ

    • value-- decrement the value by 1
    • if the value after decrementing < 0, this thread will be block ❌ β†’ don’t call on main thread
    • if the value after incrementing β‰₯ 0, no need to wait, go ahead and execute. πŸƒπŸ»β€β™€οΈ
  • signal() β†’ announce that we are done working with the shared resource

    • value++, increment the value by 1
    • if the value before incrementing < 0, it will wake the oldest thread that are waiting…
    • if the value before incrementing β‰₯ 0, no thread is waiting

Example:

 1let queue = DispatchQueue(label: "com.gcd.concurrent", attributes: .concurrent)
 2let semaphore = DispatchSemaphore(value: 3) // ← allow to run 3 threads at a time.
 3for i in 0...15 {
 4   queue.async {
 5      let songNumber = i + 1
 6      semaphore.wait()
 7      print("Downloading song", songNumber)
 8      sleep(2)
 9      print("Downloaded song", songNumber)
10      semaphore.signal()
11   }
12}
13
14/// CONSOLE
15Downloading song 1
16Downloading song 2
17Downloading song 4 
18Downloaded song 1
19Downloaded song 4
20Downloaded song 2 // done first 3
21Downloading song 3
22Downloading song 6
23Downloading song 5
24Downloaded song 3
25Downloaded song 5
26Downloaded song 6 // done next 3 songs
27Downloading song 8
28Downloading song 9
29Downloading song 7
30Downloaded song 7
31Downloaded song 9
32Downloaded song 8 // ... bla bla bla
33Downloading song 10
34Downloading song 12
35Downloading song 11
36Downloaded song 11
37Downloaded song 10
38Downloaded song 12
39Downloading song 13
40Downloading song 15
41Downloading song 14

NOTE:

  • ⚠️ NEVER call wait() on MAIN THREAD β†’ app will freeze πŸ₯Ά only from background threads.
  • can pass a timeout to wait()
  • can help in limiting the number of concurrent blocks:
 1class LimitedWorker {
 2    private let serialQueue = DispatchQueue(label: "com.khanlou.serial.queue")
 3    private let concurrentQueue = DispatchQueue(label: "com.khanlou.concurrent.queue", attributes: .concurrent)
 4    private let semaphore: DispatchSemaphore
 5
 6    init(limit: Int) {
 7        semaphore = DispatchSemaphore(value: limit)
 8    }
 9
10    func enqueue(task: @escaping () -> ()) {
11        serialQueue.async(execute: {
12            self.semaphore.wait()
13            self.concurrentQueue.async(execute: {
14                task()
15                self.semaphore.signal()
16            })
17        })
18    }
19}
  • a concurrent queue for executing the user’s tasks, allowing as many concurrently executing tasks as GCD will allow us in that queue
  • a serial queue and acts as “a gatekeeper” to the concurrent queue.
  • wait on the semaphore in the serial queue, which means that we’ll have AT MOST one blocked thread when we reach maximum executing blocks on the concurrent queue.
  • Any other tasks the user enqueues will sit on the serial queue waiting to be executed, and won’t cause new threads to be started.

DispatchGroup

β†’ helpful when execute some tasks, want to wait for ALL tasks to be completed.

 1llet backgroundQueue = DispatchQueue(label: "com.concurrent.queue", attributes: .concurrent)
 2let group = DispatchGroup()
 3for item in 1...5 {
 4    backgroundQueue.async(group: group) {
 5        sleep(UInt32(item))
 6    }
 7}
 8
 9group.notify(queue: .main) {
10    print("all tasks have been done! βœ…")
11}

more manual way with enter() and leave():

 1...
 2for item in 1...5 {
 3    group.enter()
 4    backgroundQueue.async {
 5        sleep(UInt32(item))
 6        group.leave()
 7    }
 8}
 9
10group.notify(queue: .main) {
11    print("all tasks have been done! βœ…")
12}
  • group also maintain a thread-safe, internal counter that you can manipulate
  • this counter is to make sure multiple long running tasks are ALL COMPLETED before executing a completion block
  • enter(), increment the counte, leave(), decrement the counter
  • can use wait() instead of notify(), but it will block the thread until ALL tasks finished. So, DON’T call wait on main thread.

Summary - pros and cons

  • to make the app highly responsive.
  • on OSX and iOS, the main loop, called RunLoop on the main thread, should not be blocked - only the main thread can update UI. When it is blocked, UI is not updated and the same still image is displayed for quite a long time β†’ frozen πŸ₯Ά

BUT…

  • multiple threads compete to update the same resource, it causes inconsistent data (called a race condition)
  • multiple threads await an event at the same time (deadlock)
  • thread explosion, when too many threads are used, the application memory becomes short

Learned from: