How does quality of service propagate between queues and blocks, and why does it matter for priority inversion?
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
userInitiatedto “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 lowerqos: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…