Unexpected Result with GCD Priorities
Asked Answered
S

2

7

I've been experimenting with GCD priorities recently. Here's the snippet of code that I've been working with.

for _ in 1...1000 {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
        for _ in 1...10000000 {
            let _ = sin(0.64739812)
        }
        print("Finished a default")
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0))     {
            for _ in 1...10000 {
                let _ = sin(0.64739812)
            }
            print("Finished a high")
        }
    }
}

I expected it to print

Finished a default
Finished a high
// Repeat default and high alternating back and forth 1000 times (because there are 1000 loops)

But what actually happened was the logs printed

Finished a default
Finished a high
Finished a default x 21
Finished a high
Finished a default
Finished a high x 20
Finished a default x 977
Finished a high x 978

It makes sense in the beginning, alternating a little bit. Even 21 defaults in a row makes some sense. But then it does 977 default blocks without processing a single high block. I assume this is happening because the dispatcher is very busy dealing with everything else going on. But still, it's a high priority queue vs a default priority queue.

Does anybody have any insights as to what's going on?

Edit 1

for _ in 1...1000 {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
        print("Starting a default")
        for i in 1...10000000 {
            let _ = sin(Double(i))
        }
        print("Finished a default")
    }
}
for _ in 1...1000 {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0)) {
        print("Starting a high")
        for i in 1...10000000 {
            let _ = sin(Double(i))
        }
        print("Finished a high")
    }
}

print("Done Dispatching Everything")

Here I would expect a couple defaults and a couple highs to execute before printing Done Dispatching Everything, and then to execute all the highs then all the defaults.

However, here are the results:

Starting a default x6
Done Dispatching Everything // at this point, all the high and default blocks have been successfully submitted for execution.
Starting a high
Finished a default
Starting a default
Finished a default
Starting a default
Finished a default
Starting a default
Finished a default
Starting a default
Finished a default
Starting a default
Finished a default
Starting a default
Finished a default
Starting a default
Finished a high
Starting a high
Finished a default
Starting a default
Finished a default
Finished a default
Starting a default
Starting a default
Finished a default
Starting a default
Finished a default
Starting a default
Finished a default
Starting a default
Finished a high
Starting a high
Finished a default
Starting a default
Finished a default
Starting a default
// A sequence that looks like the above for around 1500 lines.
Started+Finished a high x ~500

So what's happening is that even after everything is scheduled, default is happening significantly more than high. And then after all the defaults have finished, the highs finally start to execute and finish in bulk.

Edit 2

Another block

for _ in 1...1000 {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0)) {
        print("Starting a high")
        for i in 1...10000000 {
            let _ = sin(Double(i))
        }
        print("Finished a high")
    }
}
for _ in 1...1000 {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
        print("Starting a default")
        for i in 1...10000000 {
            let _ = sin(Double(i))
        }
        print("Finished a default")
    }
}

print("Done Dispatching Everything")

And the results blow my mind. It does the exact same thing as my second example, (Edit 1). Even though the highs all get scheduled before the defaults, it still executes the default blocks first!

Edit 3

Last example, I promise

for _ in 1...1000 {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0)) {
        print("Starting a high")
        for i in 1...10000000 {
            let _ = sin(Double(i))
        }
        print("Finished a high")
    }
}
for _ in 1...1000 {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)) {
        print("Starting a background")
        for i in 1...10000000 {
            let _ = sin(Double(i))
        }
        print("Finished a background")
    }
}

print("Done Dispatching Everything")

This executes exactly as expected. All the highs run, then all the backgrounds run, with no exceptions at all. However this is significantly different in execution than edit 2, however in theory should be the exact same.

Speckle answered 30/8, 2016 at 18:51 Comment(8)
The loop enqueues 1000 async A's to a conc-Q. If any of them completes, it'll kick off a B request. Simplified: Lets assume the DEFAULT can do 4 at once (4 threads, 1000-in). So it picks up 4, runs them. This will spawn 4 reqs to HIGH. Then it grabs the next 4 A while 4 B are running. And so on. They interleave. The original assumption is (simplified) that the 4 high-B tasks run before the next 4 A tasks are picked up from the def-Q. And hence you'll get a somewhat even distribution between A's and B's completing, because A's take much longer to execute (10000000 vs 10000 iterations).Flooded
@Flooded yes, that's what I was expecting. I am enqueueing task B (high priority) after the work in task A, but because the default queue can only process so many at a time, there should be interwoven task Bs throughout the task A executions. But it ends up just running A over and over, even though B (with a higher priority) has been successfully dispatched. So despite it's priority, it's behind in the queue.Speckle
@Speckle I don't know how long for _ in 1...10000 { sin } (or even 10000000) takes to execute on your machine, but all those prints are quite likely much slower. Your test may be flawed. Try again and capture the events in a preallocated structure (just a threadsafe array with a cursor).Flooded
Also make sure that the compiler doesn't compile either for _ in 1...10000000 { let _ = sin(0.64739812) } into a nothing (or unwraps either into a single sin(0.64) call ...Flooded
@Flooded I've added another example which more clearly shows the problem.Speckle
I tried your edit 2 on MacOS 10.12b with Swift 2.3 (just 100 iterations) on a MacBook and it runs most high first, has a small window where it interleaves (~20 reqs), and then finishes the defaults. Seems to work for me. What platform do you test on? I don't think it is out of question that HIGH=DEFAULT on some, as your 3rd sample suggests.Flooded
What about edit 2? Where you enqueue all the highs first, and then all the defaults. In that instance, you would think there is no way a default could execute before a high, right? On 9.3.5, it definitely does. I'll grab a 10b device.Speckle
Let us continue this discussion in chat.Speckle
G
1

The GCD documentation currently suggests that the default priority/QoS is fixed between userInteractive and utility. But it is not that simple: There are situations in which default is affected by the QoS of the caller (with the intent likely being to avoid accidental priority inversions, though these inversions really only happen if a caller is waiting for other work, not when dispatching asynchronously).

The GCD documentation used to have references to how default QoS/priority could be affected by the caller’s context, but this discussion no longer appears to be present in the current API documentation.

But in Energy Efficiency Guide for iOS Apps, Apple explicitly tells us that default QoS “is not intended to be used by developers to classify work.” If you want to prioritize work, stick with userInteractive, userInitiated, utility, and background.


Consider the following (where I have replaced the print statements with Instruments’ “points of interest” intervals, so we can graphically see what is going on when we profile the app):

import os.log

let signposter = OSSignposter(subsystem: "Experiment", category: .pointsOfInterest)

and

DispatchQueue.global(qos: .userInteractive).async {
    for i in 1...200 {
        DispatchQueue.global(qos: .default).async {
            signposter.withIntervalSignpost("1 default", id: signposter.makeSignpostID(), "\(i)") {
                for j in 1...100_000_000 {
                    // calculate unique result for ever iteration and check the result
                    // to ensure the compiler didn’t optimize out the calculation.

                    let result = sin(Double(j))
                    if result == 0 {
                        print("well, that was unexpected")
                    }
                }
            }
        }
    }
    for i in 1...200 {
        DispatchQueue.global(qos: .userInitiated).async {
            signposter.withIntervalSignpost("2 userInitiated", id: signposter.makeSignpostID(), "\(i)") {
                for j in 1...100_000_000 {
                    let result = sin(Double(j))
                    if result == 0 {
                        print("well, that was unexpected")
                    }
                }
            }
        }
    }
}

That yields the following, where userInitiated does not preempt the default work:

dispatched from userInteractive

However, if you replace that outer dispatch with .utility

DispatchQueue.global(qos: .utility).async {
    … // same as above
}

… then the behavior changes, with userInitiated preempting the default work:

dispatched from utility

Bottom line, the behavior of default QoS can be affected by the QoS of the calling context.


A few additional interesting observations:

  1. The default QoS “is not intended to be used by developers to classify work.” See the Energy Efficiency Guide for iOS Apps, which says:

    Special Quality of Service Classes

    In addition to the primary QoS classes, there are two special types of QoS (described in Table 4-2). In most cases, you won’t be exposed to these classes, but there is still value in knowing they exist.

    Table 4-2
    Special QoS classes

    QoS Class Description
    Default The priority level of this QoS falls between user-initiated and utility. This QoS is not intended to be used by developers to classify work. Work that has no QoS information assigned is treated as default, and the GCD global queue runs at this level.
    Unspecified This represents the absence of QoS information and cues the system that an environmental QoS should be inferred. Threads can have an unspecified QoS if they use legacy APIs that may opt the thread out of QoS.
  2. If you change the order, dispatching to userInteractive first, and then dispatching to default, you do not see the default QoS tasks preempting the userInteractive work:

    DispatchQueue.global(qos: .userInteractive).async {
        for i in 1...200 {
            DispatchQueue.global(qos: .userInitiated).async {
                signposter.withIntervalSignpost("1 userInitiated", id: signposter.makeSignpostID(), "\(i)") {
                    …
                }
            }
        }
        for i in 1...200 {
            DispatchQueue.global(qos: .default).async {
                signposter.withIntervalSignpost("2 default", id: signposter.makeSignpostID(), "\(i)") {
                    …
                }
            }
        }
    }
    

    order changed, first userInteractive and then default

    So, it is not as simple as “default = currentQoS”. The optimization is more subtle than that.

  3. Consider the scenario where you do these two sets of dispatches in separate calls (where the latter group does not have to wait for the first batch to have been dispatched):

    DispatchQueue.global(qos: .userInteractive).async {
        for i in 1...500 {
            DispatchQueue.global(qos: .default).async {
                signposter.withIntervalSignpost("1 default", id: signposter.makeSignpostID(), "\(i)") {
                    …
                }
            }
        }
    }
    
    DispatchQueue.global(qos: .userInteractive).async {
        for i in 1...500 {
            DispatchQueue.global(qos: .userInitiated).async {
                signposter.withIntervalSignpost("2 userInitiated", id: signposter.makeSignpostID(), "\(i)") {
                    …
                }
            }
        }
    }
    

    In this case, you see the tasks interspersed between each other, with no appreciable preference for one over the other:

    separate dispatches, first default, then userInitiated

    Yes, it favors the first dispatched block, but you can see if we change the order of these two separate dispatches, doing userInitiated first, you see the same behavior, favoring the first dispatched block:

    separate dispatches, but doing userInitiated first

  4. No discussion about hundreds or thousands of separate dispatches would be complete without noting that this constitutes a “thread explosion”. We would often reach for concurrentPerform, rather than a for loop, to ensure that the number of threads created does not exceed the number of processors on the device:

    DispatchQueue.global().async {
        DispatchQueue.concurrentPerform(iterations: 500) { i in
            signposter.withIntervalSignpost("1 first batch", id: signposter.makeSignpostID(), "\(i)") {
                …
            }
        }
        DispatchQueue.concurrentPerform(iterations: 500) { i in
            signposter.withIntervalSignpost("2 second batch", id: signposter.makeSignpostID(), "\(i)") {
                …
            }
        }
    }
    

    That yields:

    concurrentPerform

    Now, this offers no illumination on the default QoS/priority behavior (as I am explicitly not starting the second batch until the first batch finishes), but is just an observation that we would generally avoid unbridled dispatching to a queue, and instead use concurrentPerform.

Guayaquil answered 15/9, 2024 at 21:28 Comment(1)
Thank you so much for this detailed answer, even 8 years later! Fantastic read, well written, and excellent visual explanations to boot.Speckle
W
0

check https://en.wikipedia.org/wiki/Priority_inversion

don't print to standard output {dispatch all printing to some low priority serial queue) and check all the results again.

try this in your Playground

import Dispatch

let pq = DispatchQueue(label: "print", qos: .background)
let g = DispatchGroup()
func dprint(_ items: Any...) {
    pq.async(group: g) {
        var r = items.map{ String(describing: $0) }.joined(separator: " ")
        print(r)
    }
}

let q1 = DispatchQueue(label: "q1", qos: .background, attributes: .concurrent)
let q2 = DispatchQueue(label: "q2", qos: .userInteractive, attributes: .concurrent)

for i in 0..<10 {
    q1.async(group: g) {
        dprint("q1", i, "started")
        for i in 0..<100 {
            _ = sin(Double(i))
        }
        dprint("q1", i, "finished")
    }
}
for i in 0..<10 {
    q2.async(group: g) {
        dprint("\tq2", i, "started")
        for i in 0..<100 {
            _ = sin(Double(i))
        }
        dprint("\tq2", i, "finished")
    }
}
g.wait()
print("end of test")

it should print (don't expect the same!!) something more realistic

q1 0 started
q1 1 started
q1 2 started
    q2 0 started
    q2 1 started
    q2 2 started
    q2 3 started
    q2 0 finished
    q2 4 started
    q2 1 finished
    q2 2 finished
    q2 5 started
    q2 6 started
    q2 3 finished
    q2 7 started
    q2 6 finished
    q2 8 started
    q2 5 finished
    q2 7 finished
    q2 4 finished
    q2 9 started
    q2 8 finished
    q2 9 finished
q1 2 finished
q1 3 started
q1 0 finished
q1 4 started
q1 1 finished
q1 6 started
q1 5 started
q1 4 finished
q1 6 finished
q1 7 started
q1 8 started
q1 5 finished
q1 3 finished
q1 9 started
q1 7 finished
q1 8 finished
q1 9 finished
end of test
Worldbeater answered 19/5, 2017 at 14:55 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.