Paulo Andrade

Keeper of Secrets.

twitter github stackoverflow linkedin email
Building Expectations
Dec 17, 2018
4 minutes read

Nowadays most of our asynchronous code is handled via callbacks. But there are times where it’s a lot easier to simply wait for an asynchronous task to finish. My latest example of this was an NSOperation subclass that needed to do some network communication before it could finish. I could have made that custom operation asynchronous but it was a lot simpler to just override -main and wait for the network result.

What I needed was something akin to XCTestExpectation for generic use. I needed an object, the expectation, I could pass to the asynchronous method (or capture in a block) and simply wait on it until it finished – meaning the expectation has been fulfilled.

How it works

I quite like NSCondition. I use it a lot and it makes building this Expectation class pretty easy. So let’s start of with some state:

public class Expectation: NSObject {
    public private(set) var isFulfilled = false
    private let state = NSCondition()
}

We’ll use state as our NSCondition and isFulfilled to flag wether or not this expectation has been fulfilled.

Onwards to the logic! Lets get fulfillments out of the way first:

public func fulfill() {
    state.lock()
    isFulfilled = true
    state.broadcast()
    state.unlock()
}

If you’ve used NSCondition before this should look very familiar. We need to lock the condition first before altering our state and call broadcast to wake up all threads locked on our expectation.

Finally, we add the wait function:

public func wait(until limit: Date = Date.distantFuture) {
    state.lock()
    while !isFulfilled {
        if Date() > limit {
           break;
        }
        state.wait(until: limit)
    }
    state.unlock()
}

This is just the usual boiler plate for waiting on an NSCondition as per the documentation.

Going the extra mile

So far we’ve built a simple but useful wrapper around NSCondition but if we add just a couple of other features it can be much more powerful.

Custom conditional expression

In the custom operation example above it would be nice if while we’re waiting on the expectation we also handled the cancelation of the operation. We can accommodate this by augmenting the wait method above with another parameter:

public func wait(until limit: Date,
             while condition: @autoclosure () -> Bool = true) {
    state.lock()
    while condition() && !isFulfilled {
        if Date() > limit {
            break;
        }
        let nextTick = Date().addingTimeInterval(0.1)
        state.wait(until: nextTick)        
    }
    state.unlock()
}

Instead of just testing for isFulfilled we also need to test for condition. But for this to work we can’t just wait on the condition until the limit date like we did before or our while loop could block for the duration of allowed time. So we just wait for a small amount of time let the while loop run more often.

Using this new optional parameter in an NSOperation subclass is short & sweet:

expectation.wait(until: timeout, while: !isCancelled)

The @autoclosure annotation in the function declaration is what allows us to pass that !isCancelled expression without explicitly creating a closure.

Running the Runloop

You probably know that XCTExpectation doesn’t work exactly like what we’ve done so far. Namely, it needs to keep the runloop running to process events while it waits. This is super useful when writing tests but it’s not usually needed when waiting on a background thread.

Having said that, it’s quite easy to add support for this so we might as well do it:

public func wait(until limit: Date = Date.distantFuture,
                  runRunloop: Bool = false,
             while condition: @autoclosure () -> Bool = true) {
    state.lock()
    while condition() && !isFulfilled {
        if Date() > limit {
            break;
        }
        let nextTick = Date().addingTimeInterval(waitGranularity)
        if runRunloop {
            state.unlock()
            let ranRunloop = RunLoop.current.run(mode: .default, before: nextTick)
            state.lock()
            // if the runloop did not run at all, then wait on then condition to prevent a live loop
            if !ranRunloop {
                state.wait(until: nextTick)
            }
        }
        else {
            state.wait(until: nextTick)
        }    
    }
    state.unlock()
}

Above we added another optional parameter to control wether or not we should run the current runloop. If true, then instead of waiting on the condition, we unlock it1, run the runloop for a short amount of time and lock it again before continuing our while loop.

That’s a wrap

If you find this useful, check the accompanying repo on GitHub.


  1. The condition’s wait method actually unlocks the condition and locks it again before returning. [return]

Tags: swift iOS macOS

Back to posts