4

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!

Johnson_145
  • 1,994
  • 1
  • 17
  • 26

2 Answers2

6

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.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
-1

you wrote

Ideally, I'd like to provide the newly-created DispatchWorkItem as an additional parameter

you are wrong, to be able to cancel running task, you need a reference to it, not to the next which is ready to dispatch.

cancel() doesn't cancel running task, it only set internal "isCancel" flag by the thread-safe way, or remove the task from the queue before execution. Once executed, checking isCancel give you a chance to finish the job (early return).

import PlaygroundSupport
import Foundation

PlaygroundPage.current.needsIndefiniteExecution = true

let queue = DispatchQueue.global(qos: .background)
let prq = DispatchQueue(label: "print.queue")
var task: DispatchWorkItem?

func work(task: DispatchWorkItem?) {
    sleep(1)
    var d = Date()
    if task?.isCancelled ?? true {
        prq.async {
            print("cancelled", d)
        }
        return
    }
    sleep(3)
    d = Date()
    prq.async {
        print("finished", d)
    }
}

for _ in 0..<3  {
    task?.cancel()
    let item = DispatchWorkItem {
        work(task: task)
    }
    item.notify(queue: prq) {
        print("done")
    }
    queue.asyncAfter(deadline: .now() + 0.5, execute: item)
    task = item
    sleep(1) // comment this line
}

in this example, only the very last job is really fully executed

cancelled 2018-12-17 23:49:13 +0000
done
cancelled 2018-12-17 23:49:14 +0000
done
finished 2018-12-17 23:49:18 +0000
done

try to comment the last line and it prints

done
done
finished 2018-12-18 00:07:28 +0000
done

the difference is, that first two execution never happened. (were removed from the dispatch queue before execution)

user3441734
  • 16,722
  • 2
  • 40
  • 59