What is the difference between serial and concurrent queues and between sync and async, and what do QoS and target queues do?

10.1.1 · L2 · Interview

Reported at: Walmart

Predict first

Three blocks go onto one serial queue, two async then one sync. What does the log hold when the sync returns?

import Dispatch

let q = DispatchQueue(label: "s")
nonisolated(unsafe) var log: [String] = []
q.async { log.append("1") }
q.async { log.append("2") }
q.sync { log.append("3") }
print(log)
  • ["1", "2", "3"]
  • ["3", "1", "2"]
  • The order is not defined

Predict first

A hundred blocks increment a counter on a concurrent queue, each under a lock. What does the group wait produce?

import Foundation

let g = DispatchGroup()
let cq = DispatchQueue(label: "c", attributes: .concurrent)
nonisolated(unsafe) var n = 0
let lock = NSLock()
for _ in 0..<100 { cq.async(group: g) { lock.lock(); n += 1; lock.unlock() } }
g.wait()
print(n)
  • 100
  • Fewer than 100, some updates are lost
  • It deadlocks

Mental model. A serial queue is a single checkout lane, in order. A concurrent queue is several lanes opening as needed. sync is standing at the till until your item is rung up, async is leaving the item and walking away.

Two independent choices describe a dispatch. The queue decides how its tasks run relative to each other: a serial queue runs one task at a time, in the order submitted (the cheap way to protect state); a concurrent queue starts tasks in order but may run several at once. The call decides what the caller does next: async returns immediately and the work runs later, sync blocks the caller until the work finishes. A serial queue does not mean one particular thread; it means mutual exclusion, and only the main queue is tied to the main thread. The system provides the main queue (serial) and four global concurrent queues by quality of service; you create your own with DispatchQueue(label:) and an optional .concurrent attribute, and the label is what you see in the debugger and Instruments.

QoS (.userInteractive, .userInitiated, .default, .utility, .background) tells the system how urgent the work is, which sets thread priority, CPU and I/O scheduling and energy behaviour. Choose it by what the user is waiting for, and let it be inherited: work started from a higher-QoS context inherits that level. A target queue redirects where a queue’s work is finally executed: several private queues targeting one serial queue are serialised together, and a hierarchy lets you funnel work with a common QoS or limit concurrency. Two rules cause most real bugs. Never call sync on the queue you are already running on (a deadlock; the main-queue version is covered in the platform topic), and never use sync from a queue that the called code may call back into. Prefer async for everything that is not an intentional barrier of waiting.

A serial queue runs tasks in submission order, so by the time the sync block has run, the two earlier blocks are done too.

The lock makes each increment exclusive, and the group wait returns only after all hundred blocks finished.

Common traps

  • Calling sync on the current serial queue. The caller waits for work that is queued behind itself, so it deadlocks.
  • Assuming a serial queue always uses one thread. It guarantees exclusive, ordered execution, not a fixed thread.

They might follow up with

  • Which queue protects shared state most simply? A private serial queue, with all access going through it.
  • What does QoS inheritance mean? Work submitted from a higher-QoS context runs at that level unless you override it.

Related questions

  • 10.1.2 How do barriers, groups, semaphores and dispatch sources coordinate work?
  • 10.1.3 What are thread explosion and priority inversion, and how do QoS and the system help?
  • 6.3.3 What is the main-thread contract, and why does DispatchQueue.main.sync from the main thread crash?

How do barriers, groups, semaphores and dispatch sources coordinate… →