Avoid Queue-Jumping

17 Apr 2017 4 min read

As humans, we are great at spotting patterns, but see that pattern often enough, and it becomes familiar and eventually, effectively invisible - that clock ticking, that smudge mark on the wall or that weird smell. It's the same with code - that odd syntax or awkward call-site eventually stops standing out. That's why our projects are often cluttered with these oddities. We treat them as harmless because they become familiar, but they're not, especially for new developers trying to understand the project. DispatchQueue.main code blocks are one such pattern that we stop seeing. But having DispatchQueue.main code blocks scattered throughout our projects makes our code harder to understand and test.

Photo showing people queuing

This post will explore how we can cut that clutter by being more thoughtful about where we place those DispatchQueue.main calls.

Limiting Queue-Jumping 🦘

It's not unusual to see queue-jumping throughout a project. Take this class that undertakes asynchronous work on a background queue and returns (via a closure) the result of that work:

class Worker {
    // 1
    func performWork(completion: @escaping (Result<String, Error>) -> Void) {
        // 2
        DispatchQueue.global().async {
            // TODO: Perform work

            // 3
            completion(.success("Done"))
        }
    }
}
  1. performWork(completion:) takes a single completion closure parameter. It uses Result enum type to communicate success or failure back to the caller.
  2. The work is dispatched onto a global background queue.
  3. completion is triggered directly from inside the background queue.

Now let's see performWork(_:) being used in an example view-model:

class ViewModel {
    private let worker = Worker()

    func process() {
        worker.performWork { result in
            // 1
            DispatchQueue.main.async {
                switch result {
                case .success(let value):
                    // TODO: Do something
                case .failure(let error):
                    // TODO: Do something else
                }
            }
        }
    }
}
  1. Because performWork(_:) doesn't offer any control over the callback queue, the caller has to manually move the response handling onto the main queue - this is queue-jumping.

Every call-site that cares about which queue it's called back on will need to include this same boilerplate.

We can do better than that 😎.

Capturing the Queue

Let's update Worker to allow the caller to specify which queue to be called back on:

class Worker {
    // 1
    func performWork(callbackQueue: DispatchQueue,
                     completion: @escaping (Result<String, Error>) -> Void) {
        DispatchQueue.global().async {
            // TODO: Perform work

            // 2
            callbackQueue.async {
                completion(.success("Done"))
            }
        }
    }
}
  1. callbackQueue controls which queue the completion fires on - we're shifting control of execution context from the callee to the caller.
  2. Rather than just directly triggering completion, that triggering is now scheduled asynchronously on callbackQueue - ensuring the caller receives the callback on the queue they specified.

If this approach feels familiar, you might be thinking of the delegateQueue parameter on the URLSession which I've shamelessly been inspired by.

With Worker absorbing the queue-jumping code, we can simplify each call-site:

class ViewModel {
    private let worker = Worker()

    func process() {
        worker.performWork(callbackQueue: .main) { result in
            switch result {
            case .success(let value):
                // TODO: Do something
            case .failure(let error):
                // TODO: Do something else
            }
        }
    }
}

If we wanted to instead have result processed on another queue, it would look like:

class ViewModel {
    private let worker = Worker()
    private let processingQueue = DispatchQueue(label: "com.williamboles.processing")

    func process() {
        worker.performWork(callbackQueue: processingQueue) { result in
            switch result {
            case .success(let value):
                // TODO: Do something
            case .failure(let error):
                // TODO: Do something else
            }
        }
    }
}

With callbackQueue handling the dispatch, we've pushed the queue-jumping out of the call-site, and the responsibilities of process() are easier to see.

The callbackQueue parameter has already simplified our call-sites, but every caller still has to think about which queue they want. In practice, the most common use-case will be .main. So rather than making every call-site state the queue, we can default callbackQueue to .main:

class Worker {
    func performWork(callbackQueue: DispatchQueue = .main,
                     completion: @escaping (Result<String, Error>) -> Void) {
        // TODO: Omitted unchanged functionality
    }
}

With this change our call-sites can now look like:

class ViewModel {
    private let worker = Worker()

    func process() {
        worker.performWork { result in
            switch result {
            case .success(let value):
                // TODO: Do something
            case .failure(let error):
                // TODO: Do something else
            }
        }
    }
}

Now, callers that need the main queue don't need to think about queues at all and those that don't want the main queue can easily override the default. We've reduced friction without removing flexibility.

When the queue is hardcoded inside the method, there's no reliable way for a test to verify which queue the callback fired on. By making the callback queue injectable, we bring that behaviour under test:

final class WorkerTests: XCTestCase {
    // 1
    var sut: Worker!

    override func setUp() {
        super.setUp()

        sut = Worker()
    }

    override func tearDown() {
        sut = nil

        super.tearDown()
    }

    // 2
    func test_givenPerformWorkIsCalled_whenUsingDefaultCallbackQueue_thenCallsBackOnMainQueue() {
        let expectation = expectation(description: "completion called")

        sut.performWork { result in
            XCTAssertTrue(Thread.isMainThread)
            XCTAssertEqual(try? result.get(), "Done")

            expectation.fulfill()
        }

        waitForExpectations(timeout: 5)
    }

    // 3
    func test_givenPerformWorkIsCalled_whenSpecifyingCallbackQueue_thenCallsBackOnThatQueue() {
        let callbackQueue = DispatchQueue(label: "com.williamboles.test-callback")
        let callbackQueueKey = DispatchSpecificKey<Bool>()
        callbackQueue.setSpecific(key: callbackQueueKey, value: true)

        let expectation = expectation(description: "completion called")

        sut.performWork(callbackQueue: callbackQueue) { result in
            XCTAssertTrue(DispatchQueue.getSpecific(key: callbackQueueKey) == true)
            XCTAssertEqual(try? result.get(), "Done")

            expectation.fulfill()
        }

        waitForExpectations(timeout: 5)
    }
}
  1. sut (system under test) is created fresh in setUp and torn down after each test, ensuring a clean state for every test run.
  2. This test verifies the default behaviour - that when no callbackQueue is specified, the completion fires on the main queue. Thread.isMainThread is a straightforward check here since the main queue always maps to the main thread.
  3. This test verifies the override path - that when a custom callbackQueue is specified, the completion fires on that queue. Since there's no DispatchQueue.current API to know which queue the code is currently being run on, we tag the queue with a DispatchSpecificKey and check for it inside the callback to confirm we're on the right queue - a boolean value of true is chosen to make the assert straightforward but the real work is done with the key matching.

Wrapping Up

By adding a callbackQueue parameter to our asynchronous methods, we shift the queue-jumping responsibility from every call-site to a single, predictable location inside the callee. Most callers get the sensible default of .main for free, and the few that need something different can override it explicitly. The scattered DispatchQueue.main code blocks that had faded into the background are replaced by a single, intentional decision at the source.

To see the complete working example, visit the repository and clone the project.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.