Search code examples
swiftgrand-central-dispatchlibdispatch

DispatchSourceTimer on concurrent queue


By default when creating a DispatchSourceTimer, the default concurrent queue is used for dispatching timer events and cancellation.

What's interesting that one shot timers still dispatch the call to cancellation handler even after the event handler has already fired.

So consider the following code:

let timer = DispatchSource.makeTimerSource()

timer.setEventHandler {
    print("event handler")
}

timer.setCancelHandler {
    print("cancel handler")
}

timer.schedule(wallDeadline: .now())
timer.activate()

DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(1)) {
    timer.cancel()
}

Output:

event handler
cancel handler

Now based on documentation the call to cancel() is supposed to prevent the event handle from executing, however is it safe to assume that the call to cancel() is synchronised with the call to event handler internally?

Asynchronously cancels the dispatch source, preventing any further invocation of its event handler block.

I'd like to make sure that either one or the other is called but not both, so I modified my code and wrapped the cancel handler into DispatchWorkItem, which I cancel from the inside of the event handler:

let timer = DispatchSource.makeTimerSource()

var cancelHandler = DispatchWorkItem {
    print("cancel handler")
}

timer.setEventHandler {
    cancelHandler.cancel()
    print("event handler")
}

timer.setCancelHandler(handler: cancelHandler)
timer.schedule(wallDeadline: .now())
timer.activate()

DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
    timer.cancel()
}

However, I am not quite sure this code is thread safe. Is this code potentially prone to race condition where cancel handler executes simultaneously with event handler but before the corresponding DispatchWorkItem is cancelled?

I realise that I can probably add locks around, or use a serial queue, my question is to folks familiar with libdispatch internals.


Solution

  • As you probably know, this libDispatch code is notoriously dense. While it can be edifying to go through it, one should be reluctant to rely upon implementation details, because they are subject to change without warning. One should rely solely upon formal assurances stated in the documentation. Fortunately, setCancelHandler documentation provides such assurances:

    The cancellation handler (if specified) is submitted to the source’s target queue in response to a call to a call to the cancel() method once the system has released all references to the source’s underlying handle and the source’s event handler block has returned.

    So, in answer to your event/cancelation race, the docs are telling us that the cancelation handler will be called only after the “event handler block has returned.”


    This can be verified empirically, too, manifesting the potential race with judicious insertion of sleep calls. Consider this rendition of your second example:

    logger.log("starting timer")
    
    let queue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".timerQueue", attributes: .concurrent)
    let timer = DispatchSource.makeTimerSource(queue: queue)
    
    let cancelHandler = DispatchWorkItem {
        logger.log("cancel handler")
    }
    
    timer.setEventHandler {
        logger.log("event handler started")
        Thread.sleep(forTimeInterval: 2)           // manifest potential race
        cancelHandler.cancel()
        logger.log("event handler finished")
    }
    
    timer.setCancelHandler(handler: cancelHandler)
    timer.schedule(wallDeadline: .now() + 1)
    timer.activate()
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        logger.log("canceling timer")
        timer.cancel()
    }
    
    logger.log("done starting timer")
    

    This produces:

    2021-10-05 11:29:45.198865-0700 MyApp[18873:4847424] [ViewController] starting timer
    2021-10-05 11:29:45.199588-0700 MyApp[18873:4847424] [ViewController] done starting timer
    2021-10-05 11:29:46.199725-0700 MyApp[18873:4847502] [ViewController] event handler started
    2021-10-05 11:29:47.387352-0700 MyApp[18873:4847424] [ViewController] canceling timer
    2021-10-05 11:29:48.204222-0700 MyApp[18873:4847502] [ViewController] event handler finished
    

    Note, no “cancel handler” message.

    So, in short, we can see that GCD resolves this potential race between the event and cancellation handlers, as discussed in the documentation.