Avoid queue jumping

April 17th, 2017
#Concurrency

We are often told that no matter where we end up, we shouldn't forget where we came from. Usually, this applies to people, but it can also apply to code. Specifically, code that runs on a different queue from the queue it was scheduled on.

Photo showing people queuing

queue-jumping 🦘

It's not unusual to see queue-jumping throughout a project:

DispatchQueue.main.async { in
    //Update UI
}

Even with the syntax improvements of using GCD introduced in Swift 3, having these GCD calls littered in our code acts as an interruption to its normal flow making the code harder to read, debug and test.

The above queue-jumping code is littered throughout our code because a lot of the 3rd party code we interact with doesn't guarantee the queue their callbacks happen on. So if that callback is triggering a UI update, then we need to wrap that UI update code in a GCD block like shown above. Things can get messy very quickly.

However, in the code that we write, we can avoid this queue-jumping by ensuring that the queue any callbacks are called on is known and configurable.

The approach you will see below is similar to the delegateQueue parameter on the URLSession init'er but with a defaulted parameter twist.

Capturing the queue 🕸️

For this example, I'm going to use an OperationQueue to schedule work on a background queue in the form of Operation instances.

Before we get into the code, it's important to note that an operation is executed in parts with each part being executed on different queues:

  • Setup is performed on the caller's queue.
  • Execution is performed on the queue the operation was added to.

So we can capture the caller's queue during setup:

class CapturingOperation: Operation {

    private let completion: ((_ result: Result) -> ())?
    private let callbackQueue: OperationQueue?

    //MARK: - Init

    init(callbackQueue: OperationQueue? = OperationQueue.current, completion: ((_ result: Result) -> ())?) {
        self.completion = completion
        self.callbackQueue = callbackQueue

        super.init()
    }
}

The above subclass of Operation has two private properties and takes two parameters in it's init'er:

  1. callbackQueue is the queue which any callbacks will be executed on. callbackQueue defaults to capture the queue which the CapturingOperation instance was scheduled on however this behaviour can be overridden by the user of this class explicitly passing in a queue instance.
  2. completion is the closure that will be triggered when this operation's work is completed. It uses the Result enum type that allows us to encapsulate both the success and failure outcomes in one closure.

You may be thinking "Why use a custom completion closure when Operation comes with a completion closure?". Well first, the completionBlock closure is executed on a random background queue even if we use the GCD queue switching technique above to push it onto a specific, and secondly, the completionBlock closure is very limited in that you can't pass data back through the closure. Due to both of these limitations, I always use a custom closure.

Now that we have our completion closure and callback queued stored as properties, it's time to perform the task that the operation was created for:

class CapturingOperation: Operation {
    // Omitted properties and methods

    override func main() {
        super.main()

        //Operation's actual work happens here

        let result = Result.success(true)
        if let callbackQueue = callbackQueue {
            callbackQueue.addOperation {
                self.completion?(result)
            }
        } else {
            completion?(result)
        }
    }
}

In the above main method, once the operation's task is complete, the completion closure is triggered. If the callbackQueue is non-nil, the triggering of the completion closure happens on that queue; if it's nil, then the completion closure is triggered directly from the queue that the operation is running on.

With the queue-jumping happening inside the operation itself, we can safely remove any queue-jumping in the code that uses this operation - simplifying multiple call-sites at the expense of making triggering this operation's completion closure more complex. Spock would be proud.

Calling home has never been so easy

This approach makes a sensible assumption that the queue that you scheduled an operation from is the queue that you want that operation to be called back on. By using a defaulted parameter (callbackQueue) in the init'er if that assumption proves to be false, the callback queue can be overridden with a more appropriate queue.

To see this project in action head over to the repo and clone the project.

What do you think? Let me know by getting in touch on Twitter - @wibosco