How does quality of service propagate between queues and blocks, and why does it matter for priority inversion?

10.11.5 · L5 · Interview

Predict first

What QoS do the nested block and the main thread report?

import Foundation

func qos() -> String { String(describing: DispatchQoS.QoSClass(rawValue: qos_class_self()) ?? .unspecified) }
let done = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
    print("userInitiated block:", qos())
    DispatchQueue.global().async { print("nested async on the default-QoS queue:", qos()); done.signal() }
}
done.wait()
print("main thread:", qos())
  • userInitiated block: userInitiated / nested async on the default-QoS queue: userInitiated / main thread: userInteractive
  • userInitiated block: userInitiated / nested async on the default-QoS queue: default / main thread: userInteractive
  • userInitiated block: default / nested async on the default-QoS queue: default / main thread: default

Mental model. If a VIP asks a clerk to do something, the clerk’s own task gets VIP treatment too, even though the clerk is junior. If a junior asks for something, the task is junior however grand the department sounds.

Every block has a QoS (userInteractive, userInitiated, utility, background, default). A block submitted with async from another block inherits the submitter’s QoS when the destination queue has no QoS of its own, or a lower one (the system raises the work to match the caller). In the check a block on a userInitiated queue submits to the plain default-QoS global queue, and the nested block reports userInitiated, not default. The main thread runs at userInteractive. This is why background work started from a user action stays responsive, and why work you start from a low-QoS block stays low, whatever the destination queue is called.

Inheritance and promotion are the system’s defence against priority inversion, where a high-priority thread waits for a low-priority one (Q290): GCD raises a queue’s effective QoS while a higher-priority caller waits on it with sync, and a lock or semaphore with no owner information cannot be boosted, which is one more reason to prefer real locks and queues over semaphores for mutual exclusion. Practical rules: choose QoS by user impact (not importance to you), let it flow from the caller, set it explicitly only on long-lived queues, avoid userInteractive for anything but UI-critical work, and for tasks the same idea is priority escalation. Do not rely on the exact promoted value in your logic: use it to understand why work is slow or fast, and to look at QoS in Instruments.

A nested block inherits the higher QoS of its submitter, and the main thread is userInteractive.

Common traps

  • Putting everything at userInitiated to “make it fast”. It competes with the UI and drains battery; QoS is relative.
  • Using a semaphore or flag spin for mutual exclusion. The system cannot see who owns it, so it cannot fix priority inversion.

They might follow up with

  • Does a QoS override on async(qos:) always lower it? Not reliably; the effective QoS can still be raised by the queue or the caller, so treat the argument as a hint (in a quick experiment, a lower qos: on a block submitted from a higher-QoS context still ran at the higher one).
  • What does the main thread run at? userInteractive, as the check prints.

Related questions

  • 10.11.2 What does DispatchQueue.concurrentPerform do, when is it better than a loop of async calls, and how do you…
  • 10.11.4 How do Operation dependencies, cancellation and states interact, and what happens to a dependent operation…
  • 10.11.3 How does a DispatchSourceTimer differ from Timer, and what are the lifecycle rules that crash if you break…
  • 10.1.1 What is the difference between serial and concurrent queues and between sync and async, and what do QoS and…
  • 10.1.3 What are thread explosion and priority inversion, and how do QoS and the system help?

← How do Operation dependencies, cancellation and states interact, and…