Search code examples
iosswiftconcurrencygrand-central-dispatch

How to get cancellation state for multiple DispatchWorkItems


Background

I'm implementing a search. Each search query results in one DispatchWorkItem which is then queued for execution. As the user can trigger a new search faster than the previous one can be completed, I'd like to cancel the previous one as soon as I receive a new one.

This is my current setup:

var currentSearchJob: DispatchWorkItem?
let searchJobQueue = DispatchQueue(label: QUEUE_KEY)

func updateSearchResults(for searchController: UISearchController) {
    let queryString = searchController.searchBar.text?.lowercased() ?? ""

    // if there is already an (older) search job running, cancel it
    currentSearchJob?.cancel()

    // create a new search job
    currentSearchJob = DispatchWorkItem() {
        self.filter(queryString: queryString)
    }

    // start the new job
    searchJobQueue.async(execute: currentSearchJob!)
}

Problem

I understand that dispatchWorkItem.cancel() doesn't kill the running task immediately. Instead, I need to check for dispatchWorkItem.isCancelled manually. But how do I get the right dispatchWorkItemobject in this case?

If I were setting currentSearchJob only once, I could simply access that attribute like done in this case. However, this isn't applicable here, because the attribute will be overriden before the filter() method will be finished. How do I know which instance is actually running the code in which I want to check for dispatchWorkItem.isCancelled?

Ideally, I'd like to provide the newly-created DispatchWorkItem as an additional parameter to the filter() method. But that's not possible, because I'll get a Variable used within its own initial value error.

I'm new to Swift, so I hope I'm just missing something. Any help is appreciated very much!


Solution

  • The trick is how to have a dispatched task check if it has been canceled. I'd actually suggest consider OperationQueue approach, rather than using dispatch queues directly.

    There are at least two approaches:

    • Most elegant, IMHO, is to just subclass Operation, passing whatever you want to it in the init method, and performing the work in the main method:

       class SearchOperation: Operation {
           private var queryString: String
      
           init(queryString: String) { 
               self.queryString = queryString
               super.init()
           }
      
           override func main() {
               // do something synchronous, periodically checking `isCancelled`
               // e.g., for illustrative purposes
      
               print("starting \(queryString)")
               for i in 0 ... 10 {
                   if isCancelled { print("canceled \(queryString)"); return }
                   print("  \(queryString): \(i)")
                   heavyWork()
               }
               print("finished \(queryString)")
           }
      
           func heavyWork() {
               Thread.sleep(forTimeInterval: 0.5)
           }
       }
      

      Because that's in an Operation subclass, isCancelled is implicitly referencing itself rather than some ivar, avoiding any confusion about what it's checking. And your "start a new query" code can just say "cancel anything currently on the the relevant operation queue and add a new operation onto that queue":

       private var searchQueue: OperationQueue = {
           let queue = OperationQueue()
           // queue.maxConcurrentOperationCount = 1  // make it serial if you want
           queue.name = Bundle.main.bundleIdentifier! + ".backgroundQueue"
           return queue
       }()
      
       func performSearch(for queryString: String) {
           searchQueue.cancelAllOperations()
           let operation = SearchOperation(queryString: queryString)
           searchQueue.addOperation(operation)
       }
      

      I recommend this approach as you end up with a small cohesive object, the operation, that nicely encapsulates a block of work that you want to do, in the spirit of the Single Responsibility Principle.

    • While the following is less elegant, technically you can also use BlockOperation, which is block-based, but for which which you can decouple the creation of the operation, and the adding of the closure to the operation. Using this technique, you can actually pass a reference to the operation to its own closure:

       private weak var lastOperation: Operation?
      
       func performSearch(for queryString: String) {
           lastOperation?.cancel()
      
           let operation = BlockOperation()
           operation.addExecutionBlock { [weak operation, weak self] in
               print("starting \(identifier)")
               for i in 0 ... 10 {
                   if operation?.isCancelled ?? true { print("canceled \(identifier)"); return }
                   print("  \(identifier): \(i)")
                   self?.heavyWork()
               }
               print("finished \(identifier)")
           }
           searchQueue.addOperation(operation)
           lastOperation = operation
       }
      
       func heavyWork() {
           Thread.sleep(forTimeInterval: 0.5)
       }
      

      I only mention this for the sake of completeness. I think the Operation subclass approach is frequently a better design. I'll use BlockOperation for one-off sort of stuff, but as soon as I want more sophisticated cancelation logic, I think the Operation subclass approach is better.

    I should also mention that, in addition to more elegant cancelation capabilities, Operation objects offer all sorts of other sophisticated capabilities (e.g. asynchronously manage queue of tasks that are, themselves, asynchronous; constrain degree of concurrency; etc.). This is all beyond the scope of this question.