8

Im doing some lengthy calculations to create chart data on a background thread

i was originally use GCD, but every time a user filters the chart data by hitting a button, the chart data needs to be recalculated, if the user clicks the chart data filtering buttons very quickly (power user) then the chart loops through each drawing as each GCD dispatch async finishes

I realize that I can't cancel threads with GCD so I've moved to trying to implement an OperationQueue

I call cancelAllOperations() before adding a new operation to the queue

The operations on the queue act funky, sometimes it seems like they are cancelled, some times it seems like the one that finished is not the most recent one put on the queue.

I am also having trouble cancelling a executing operation as the operation's .isCancelled property is never true when i check for it in the operations completion block

What i really want is if the chart data calculation is currently happening in a background thread, and a user clicks another filter button and kicks off another chart calculation on a background thread, the previous chart background thread calculation is terminated and "replaced" with the most recently added operation

is this possible? here is some code:

func setHistoricalChart() -> Void {
    self.lineChartView.clear()
    self.lineChartView.noDataText = "Calculating Historical Totals, Please Wait..."

    self.historicalOperationsQueue.qualityOfService = .utility
    self.historicalOperationsQueue.maxConcurrentOperationCount = 1
    self.historicalOperationsQueue.name = "historical operations queue"

    let historicalOperation = Operation()
    historicalOperation.completionBlock = { [weak self] in
        //dictionary of feeds, array of data for each feed
        var valuesByFeed = [String:[String]?]()
        var dates = [String:[String]?]()
        var chartDataSets = [IChartDataSet]()

        //get data and values from DataMOs in the activeFeeds
        if (self?.activeFeeds.count)! > 0 {
            //check if operation is cancelled
            if historicalOperation.isCancelled {
                return
            }
            for (key, feed) in (self?.activeFeeds)! {
                dates[key] = feed?.datas?.flatMap({ Utils.formatUTCDateString(utcDateString: ($0 as! DataMO).utcDateString) })
                valuesByFeed[key] = feed?.datas?
                    .sorted(by: { (($0 as! DataMO).utcDateString)! < (($1 as! DataMO).utcDateString)! })
                    .flatMap({ ($0 as! DataMO).value })
            }

            //Create Chart Data
            for (key, valuesArray) in valuesByFeed {
                var dataEntries = [ChartDataEntry]()
                for (index, value) in (valuesArray?.enumerated())! {
                    let dataEntry = ChartDataEntry(x: Double(index), y: Double(value)!)
                    dataEntries.append(dataEntry)
                }
                let singleChartDataSet = LineChartDataSet(values: dataEntries, label: key)
                singleChartDataSet.drawCirclesEnabled = false
                switch key {
                case "Solar":
                    singleChartDataSet.setColors(UIColor(red: 230/255, green: 168/255, blue: 46/255, alpha: 1))
                    singleChartDataSet.drawFilledEnabled = true
                    singleChartDataSet.fillColor = UIColor(red: 230/255, green: 168/255, blue: 46/255, alpha: 0.8)
                    break
                case "Wind":
                    singleChartDataSet.setColors(UIColor(red: 73/255, green: 144/255, blue: 226/255, alpha: 1))
                    singleChartDataSet.drawFilledEnabled = true
                    singleChartDataSet.fillColor = UIColor(red: 73/255, green: 144/255, blue: 226/255, alpha: 0.8)
                    break
                case "Battery":
                    singleChartDataSet.setColors(UIColor(red: 126/255, green: 211/255, blue: 33/255, alpha: 1))
                    singleChartDataSet.drawFilledEnabled = true
                    singleChartDataSet.fillColor = UIColor(red: 126/255, green: 211/255, blue: 33/255, alpha: 0.8)
                    break
                case "Gen":
                    singleChartDataSet.setColors(UIColor(red: 208/255, green: 1/255, blue: 27/255, alpha: 1))
                    singleChartDataSet.drawFilledEnabled = true
                    singleChartDataSet.fillColor = UIColor(red: 208/255, green: 1/255, blue: 27/255, alpha: 0.8)
                    break
                case "Demand":
                    singleChartDataSet.setColors(UIColor(red: 128/255, green: 133/255, blue: 233/255, alpha: 1))
                    singleChartDataSet.drawFilledEnabled = true
                    singleChartDataSet.fillColor = UIColor(red: 128/255, green: 133/255, blue: 233/255, alpha: 0.8)
                    break
                case "Prod":
                    singleChartDataSet.setColors(UIColor(red: 241/255, green: 92/255, blue: 128/255, alpha: 1))
                    singleChartDataSet.drawFilledEnabled = true
                    singleChartDataSet.fillColor = UIColor(red: 241/255, green: 92/255, blue: 128/255, alpha: 0.8)
                    break
                default:
                    break
                }
                chartDataSets.append(singleChartDataSet)
            }
        }

        //check if operation is cancelled
        if historicalOperation.isCancelled {
            return
        }

        //set chart data
        let chartData = LineChartData(dataSets: chartDataSets)

        //update UI on MainThread
        OperationQueue.main.addOperation({
            if (self?.activeFeeds.count)! > 0 {
                self?.lineChartView.data = chartData
            } else {
                self?.lineChartView.clear()
                self?.lineChartView.noDataText = "No Feeds To Show"
            }
        })
    }
    historicalOperationsQueue.cancelAllOperations()
    historicalOperationsQueue.addOperation(historicalOperation)
}

enter image description here

Derek Hannah
  • 537
  • 7
  • 23
  • 1
    Cancelling an operation is susceptible to race condition, you cannot avoid that. It can be beneficial for you to schedule all the time-consuming calls at the beginning of the operation, before you update the GUI or any non-local state variables. What are the most time-consuming functions in your operation? – Code Different Jul 28 '17 at 20:47
  • 1
    I also recommend [Advanced NSOperations](https://developer.apple.com/videos/play/wwdc2015/226/?time=307) from WWDC 2015. Cancellation is discussed around the 5:00 mark – Code Different Jul 28 '17 at 20:51
  • The problem is that this entire function gets called every time a user hits a button, I want to avoid the user clicking a button fast three times, and then the chart being slowly updated three times, it seems lagy to the users when it does this – Derek Hannah Jul 28 '17 at 21:12
  • 1
    Then disable the refresh button when an update is on-going make more sense, no? – Code Different Jul 28 '17 at 21:14
  • I added a screen shot, as you can see the users can click a button to add or remove data points on the chart, its the chart data calculations that happen on the .utility queue. Do you really think its the best user experience to disable the data filtering buttons when chart data is begin calculated on a background thread? is that a common iOS ux practice? – Derek Hannah Jul 28 '17 at 23:29
  • chart data creation can sometimes take a few sec, even on a background thread, heavy calculations going on – Derek Hannah Jul 28 '17 at 23:31
  • FWIW, I would not recommend [Advanced NSOperations](https://developer.apple.com/videos/play/wwdc2015/226/?time=307). At best, it's horrible overkill for this scenario. At worst, I consider that one of Apple's more embarrassing, over-engineered pieces of code and it does nothing to remedy the above. But all of this is moot, as the problem with the above code is a simple mistake in the cancelation logic, which renders running operations non-cancelable. – Rob Jul 30 '17 at 18:42
  • By the way, it strikes me that there might be further opportunity to streamline this process (e.g. instead of one operation to generate all of these charts, have one operation per chart, offering greater parallelization options and update the UI as the individual charts are done). But certainly solve the above before you consider refactoring this. – Rob Jul 30 '17 at 18:48
  • I answered it here with example, you can take a look of it:- [Pause and Resume your OperationQueue](https://stackoverflow.com/a/62930025/9863222) – Vipul Kumar Jul 16 '20 at 08:09

1 Answers1

15

I realize that I can't cancel threads with GCD ...

Just as an aside, that's not entirely true. You can cancel DispatchWorkItem items dispatched to a GCD queue:

var item: DispatchWorkItem!
item = DispatchWorkItem {
    ...

    while notYetDone() {
        if item.isCancelled {
            os_log("canceled")
            return
        }

        ...
    }

    os_log("finished")
}

let queue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".customQueue")

queue.async(execute: item)

// just to prove it's cancelable, let's cancel it one second later

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    os_log("canceling")
    item.cancel()
}

Admittedly, you have to cancel individual DispatchWorkItem instances, but it does work.

... so I've moved to trying to implement an OperationQueue

Unfortunately, this has not been implemented correctly. In short, the code in your question is creating an operation that does nothing in the body of the operation itself, but instead has all of the computationally intensive code in its completion handler. But this completion handler is only called after the operation is “completed”. And completed operations (ie., those already running their completion handlers) cannot be canceled. Thus, the operation will ignore attempts to cancel these ongoing, time-consuming completion handler blocks.

Instead, create an block operation, and add your logic as a "execution block", not a completion handler. Then cancelation works as expected:

let operation = BlockOperation()
operation.addExecutionBlock {
    ...

    while notYetDone() {
        if operation.isCancelled {
            os_log("canceled")
            return
        }

        ...
    }

    os_log("finished")
}

let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1

queue.addOperation(operation)

// just to prove it's cancelable, let's cancel it

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    os_log("canceling")
    operation.cancel()
}

Or, perhaps even better, create an Operation subclass that does this work. One of the advantages of Operation and OperationQueue has that you can disentangle the complicated operation code from the view controller code.

For example:

class ChartOperation: Operation {

    var feeds: [Feed]
    private var chartOperationCompletion: (([IChartDataSet]?) -> Void)?

    init(feeds: [Feed], completion: (([IChartDataSet]?) -> Void)? = nil) {
        self.feeds = feeds
        self.chartOperationCompletion = completion
        super.init()
    }

    override func main() {
        let results = [IChartDataSet]()

        while notYetDone() {
            if isCancelled {
                OperationQueue.main.addOperation {
                    self.chartOperationCompletion?(nil)
                    self.chartOperationCompletion = nil
                }
                return
            }

            ...
        }

        OperationQueue.main.addOperation {
            self.chartOperationCompletion?(results)
            self.chartOperationCompletion = nil
        }
    }

}

I didn't know what your activeFeeds was, so I declared it as an array of Feed, but adjust as you see fit. But it illustrates the idea for synchronous operations: Just subclass Operation and add a main method. If you want to pass data to the operation, add that as a parameter to the init method. If you want to pass data back, add a closure parameter which will be called when the operation is done. Note, I prefer this to relying on the built-in completionHandler because that doesn't offer the opportunity to supply parameters to be passed to the closure like the above custom completion handler does.

Anyway, your view controller can do something like:

let operation = ChartOperation(feeds: activeFeeds) { results in
    // update UI here
}

queue.addOperation(operation)

And this, like the examples above, is cancelable.


By the way, while I show how to ensure the operation is cancelable, you may also want to make sure you're checking isCancelled inside your various for loops (or perhaps just at the most deeply nested for loop). As it is, you're checking isCancelled early in the process, and if you don't check it later, it will ignore subsequent cancelations. Dispatch and operation queues do not perform preemptive cancelations, so you have to insert your isCancelled checks at whatever points you'd like cancelations to be recognized.

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