I have implemented a custom concurrent queue on top of GCD which offers two additional pieces of functionality:
- Limit the maximum number of concurrently executing tasks.
- 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:
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.
Needing three queues and a semaphore to coordinate everything isn't great. Am I trying to be too clever?
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.
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\$DispatchQueue
to automatically generate a label. \$\endgroup\$queuePriority
for operations added to anOperationQueue
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\$