How is DispatchQueue QoS resolution intended to work when both `qos` and a target queue are specified?

Per my understanding of the DispatchQueue docs, and various WWDC videos on the matter, if one creates a queue in the following manner:

let q = DisqpatchQueue(
  label: "my-q",
  qos: .utility,
  target: .global(qos: .userInteractive)
)

then one should expect work items submitted via async() to effectively run at userInteractive QoS, as the target queue should provide a 'floor' on the effective QoS value (assuming no additional rules are in play, e.g. higher priority items have been enqueued, submitted work items enforce QoS, etc).

In practice, however, this particular formulation does not appear to function that way, and the 'resolved' QoS value seems to be utility, contrary to what the potentially relevant documentation suggests. This behavior appears to be inconsistent with other permutations of queue construction, which makes it even more surprising.

Here's some sample code I was experimenting with to check the behavior of queues created in various ways that I would expect to function analogously (in regards to the derived QoS value for the threads executing their work items):

func test_qos_permutations() {
    // q1
    let utilTargetingGlobalUIQ = DispatchQueue(
        label: "qos:util tgt:globalUI",
        qos: .utility,
        target: .global(qos: .userInitiated)
    )

    let customUITargetQ = DispatchQueue(
        label: "custom tgt, qos: unspec, tgt:globalUI",
        target: .global(qos: .userInitiated)
    )

    // q2
    let utilTargetingCustomSerialUIQ = DispatchQueue(
        label: "qos:util tgt:customSerialUI",
        qos: .utility,
        target: customUITargetQ
    )

    // q3
    let utilDelayedTargetingGlobalUIQ = DispatchQueue(
        label: "qos:util tgt:globalUI-delayed",
        qos: .utility,
        attributes: .initiallyInactive
    )
    utilDelayedTargetingGlobalUIQ.setTarget(queue: .global(qos: .userInitiated))
    utilDelayedTargetingGlobalUIQ.activate()

    let queues = [
        utilTargetingGlobalUIQ,
        utilTargetingCustomSerialUIQ,
        utilDelayedTargetingGlobalUIQ,
    ]
    for q in queues {
        q.async {
            Thread.current.name = q.label
            let threadQos = qos_class_self()
            print("""
            q: \(q.label)
                orig qosClass: \(q.qos.qosClass)
                thread qosClass: \(DispatchQoS.QoSClass(rawValue: threadQos)!)
            """)
        }
    }
}

Running this, I get the following output:

q: qos:util tgt:customSerialUI
    orig qosClass: utility
    thread qosClass: userInitiated
q: qos:util tgt:globalUI-delayed
    orig qosClass: utility
    thread qosClass: userInitiated
q: qos:util tgt:globalUI
    orig qosClass: utility
    thread qosClass: utility

This test suggests that constructing a queue with an explicit qos parameter and targeting a global queue of nominally 'higher' QoS does not result in a queue that runs its items at the target's QoS. Perhaps most surprisingly is that if the target queue is set after the queue was initialized, you do get the expected 'QoS floor' behavior. Is this behavior expected, or possibly a bug?

Replies

I don’t have time to properly digest your post today, but I want to point out a couple of things that might have you better understand things.

First, qos_class_self doesn’t report QoS overrides. The doc comments in <pthread/qos.h> says:

Starting a QOS class override does not modify the target thread's requested QOS class value and the effect of an override is not visible to the qos_class_self() and pthread_get_qos_class_np() interfaces.

Second, I have a couple of go-to tools for investigating stuff like this:

  • The System Trace template in Instruments, and specifically Thread State Trace instrument

  • The taskinfo command-line tool

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks for the feedback @eskimo. You actually helped clarify the point about QoS overrides not being reported via qos_class_self() in a recent other question of mine, which was very helpful in this subsequent investigation. The reason I used qos_class_self() in the example was mostly for convenience, since both the other tools require a bit more overhead to illustrate the same thing (though I have used taskinfo and it reports the same behavior).

I believe the topic here is a bit different than QoS 'overrides', as it's more a question of how a queue's QoS is to be resolved when there is ambiguity between the explicit qos parameter and the QoS value of its target queue. IIUC, this value should essentially be known (or knowable) when the queue is constructed, at least in most cases. In the 'Modernizing Grand Central Dispatch Usage' from WWDC 2017, there is a segment explaining how queue hierarchies are supposed to interact with QoS, and at one point it's stated that:

Another common use case would be to put a label on the mutual exclusion queue to provide a floor of execution so that nothing in this tree can execute below this level, so [utility] in this example.

To me, this, along with the documentation for dispatch_set_target_queue which states:

A dispatch queue inherits the minimum quality-of-service level from its target queue.

suggests that the QoS of the target queue should govern the minimum 'effective' QoS for submitted work items (again, assuming no other QoS rules are in play).

However, upon further research, I did find that there is this additional statement from the docs for dispatch_queue_attr_make_with_qos_class alluding to this issue:

The quality-of-service value you specify using this function takes precedence over the priority level inherited from the dispatch queue’s target queue.

That, combined with what looks like the relevant logic from the open source libdispatch code probably explains why I'm observing this behavior, though it still puzzles me if this is actually intended. I guess fundamentally, I would assume that both creating a queue and immediately setting its target or using the 'atomic' constructor that does both things at once would result in the same end state, at least in regards to the QoS topic here.