2
\$\begingroup\$

I have implemented a custom concurrent queue on top of GCD which offers two additional pieces of functionality:

  1. Limit the maximum number of concurrently executing tasks.
  2. Give manual control over which enqueued task is scheduled for execution next.

An OperationQueue could of course address the former but gives no control over the latter. Being able to influence the order in which the queue executes its task is however beneficial for the purposes of my app (anticipating and delaying expensive tasks that are likely to get cancelled), hence my attempt at this implementation.

I have multiple concerns:

  1. The fact that this is one class providing two distinct pieces of functionality seems like a red flag. Implementing this in two separate types, possibly with one subclassing the other, would probably be better. But I can't see a straightforward way to untangle this without overcomplicating things.

  2. Needing three queues and a semaphore to coordinate everything isn't great. Am I trying to be too clever?

  3. Looking through some of the libdispatch guidelines, particularly this post by the maintainer, has me wondering if this entire use case is a bit of an anti-pattern anyway (specifically regarding the use of semaphores to wait for asynchronous work). Is it simply a bad idea to try retrofitting this kind of functionality onto GCD?

Given my initial requirements (i.e. limited bandwidth and custom scheduling order), is this approach remotely reasonable? If so, how could it be improved?

Thanks in advance!

final class Queue {

    let count: Int

    private let block: (Range<Int>) -> Int

    private let semaphore: DispatchSemaphore

    private let objectQueue: DispatchQueue

    private let semaphoreQueue: DispatchQueue

    private let workItemQueue: DispatchQueue

    private var workItems = Array<DispatchWorkItem>()

    init(count: Int, qos: DispatchQoS = .default, block: @escaping (Range<Int>) -> Int) {
        self.count = count

        self.block = block

        self.semaphore = .init(value: count)

        self.objectQueue = .init(label: "object-queue", qos: qos)

        self.semaphoreQueue = .init(label: "semaphore-queue", qos: qos)

        self.workItemQueue = .init(label: "workitem-queue", qos: qos, attributes: [.concurrent])
    }

}

extension Queue {

    func async(execute work: @escaping () -> ()) {
        objectQueue.async { [self] in
            let workItem = DispatchWorkItem(flags: [.inheritQoS]) {
                work()

                semaphore.signal()
            }

            workItems.append(workItem)

            semaphoreQueue.async {
                semaphore.wait()

                objectQueue.async {
                    let index = block(workItems.indices)

                    let workItem = workItems.remove(at: index)

                    workItemQueue.async(execute: workItem)
                }
            }
        }
    }

    func async(execute workItem: DispatchWorkItem) {
        async { workItem.perform() }
    }

}

Edit

The block closure selects the index of the next-to-be-scheduled work item. Its argument Range is guaranteed to be non-empty.

The work items are in the same order in which they were enqueued. block isn't intended to relate to specific work items but rather to make a decision between choosing ones that have been awaiting execution for a while vs. ones that just arrived in the queue.

I use it in this specific way:

let count = max(1, ProcessInfo.processInfo.activeProcessorCount - 2)

let queue = Queue(count: count) { indices in
    let value = sqrt(.random(in: 0 ..< 1))

    let index = Int(value * Double(indices.count))

    return indices[index]
}

In this configuration the queue picks work items at random, skewing towards newer ones. This might seem counterintuitive, but when dealing with a lot of expensive tasks which are subject to likely cancellation (in my case loading resources for display in a collection view while scrolling), this results in a measurable performance improvement.

\$\endgroup\$
14
  • \$\begingroup\$ If I understand it correctly, the block callback is called when an execution slot is available, and that returns the index of the next work item to be executed. But how does the callback make that decision bases on indices only, if there is no (public) correspondence between the indices and the work items? – A small usage example might be helpful. \$\endgroup\$
    – Martin R
    Mar 29, 2021 at 9:41
  • \$\begingroup\$ Btw, your code fails to compile in my Xcode 12 with some “Missing argument for parameter 'label' in call“ errors. \$\endgroup\$
    – Martin R
    Mar 29, 2021 at 9:42
  • \$\begingroup\$ @MartinR Oh, I forgot I had an extension on DispatchQueue to automatically generate a label. \$\endgroup\$ Mar 29, 2021 at 10:11
  • 1
    \$\begingroup\$ @MartinR Added an example use case. \$\endgroup\$ Mar 29, 2021 at 10:13
  • \$\begingroup\$ I'm trying to understand what you're doing here that simply setting queuePriority for operations added to an OperationQueue would not achieve. E.g. here I added 25 low priority tasks (in green) to a queue with a max concurrency of 6, then added 25 normal priority tasks (in yellow), and the operation queue correctly prioritized the higher priority tasks ahead of the low priority tasks that were added to the queue before the default priority tasks (as soon as, of course, the queue was free). \$\endgroup\$
    – Rob
    Sep 17, 2021 at 1:51

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.