0

I have some hard to solve crashes. They happen in application from time to time not in regular way. I think the problem maybe with some race conditions and synchronizations.

I am using such pattern.

1) reloadData()

2) reloadSection1(), reloadSection2(), reloadSection2(), etc.

3) I have tap events that can do reload like reloadData()

4) There are also Socket.IO messages that can cause reloadData(), reloadSectionN()

5) I try to use debouncers to execute only last reload of given type.

6) debouncers use Serial Queue to execute task serially one by one in case that request-response-reload last longer then new socket.io event arrives

7) section/table reloadin must happen on UI thread so at the end I moves to it using DispatchQueue.main.async { }

8) I have even tried to wrap data excesses/ reloads into semaphores to block modification by other threads.

But errors can happens and app can crash in spite of this. And I have no idea what may cause it.

Below I place the most important parts of code.

I have such instance properties:

private let debouncer = Debouncer()
private let debouncer1 = Debouncer()
private let debouncer2 = Debouncer()
private let serialQueue = DispatchQueue(label: "serialqueue")
private let semaphore = DispatchSemaphore(value: 1)

Here Debouncer.debounce instance method

func debounce(delay: DispatchTimeInterval, queue: DispatchQueue = .main, action: @escaping (() -> Void) ) -> () -> Void {
        return {  [weak self] in
            guard let self = self else { return }
            self.currentWorkItem?.cancel()
            self.currentWorkItem = DispatchWorkItem {
                action()
            }

            if let workItem = self.currentWorkItem {
                queue.asyncAfter(deadline: .now() + delay, execute: workItem)
            }
        }
    }

Here are debounced reloads of table view and its sections

 func debounceReload() {
        let debounceReload = debouncer.debounce(delay: .milliseconds(500), queue: serialQueue) {
            self.reloadData()
        }

        debounceReload()
    }

func debounceReloadOrders() {

        let debounceReload = debouncer1.debounce(delay: .milliseconds(500), queue: serialQueue) {
            self.reloadOrdersSection(animating: false)
        }

        debounceReload()
    }

This debounce methods can be invoked on tap, pull to refresh screen navigation, or Socket.IO events (here possible multiple events at the same time if there are multiple users).

Each reload called by debounce reload start and ends with such methods (in between there are synchronous requests to remote api that may take some time (and are executed on this serialqueue). All debouncers reuse the same serial queue (so they should not conflict/race with each other and caused data inconsistency while reloading tableview or its sections).

private func startLoading() {
        print("startLoading")
        activityIndicator.startAnimating()
        activityIndicator.isHidden = false
        tableView.isHidden = true

        // cancel section reload
        debouncer1.currentWorkItem?.cancel()
        debouncer2.currentWorkItem?.cancel()
    } 

private func stopLoading() {
        guard debouncer.currentWorkItem?.isCancelled != true else { return }

        self.semaphore.wait()
        print("stopLoading")

        tableView.reloadData()
        activityIndicator.isHidden = true
        refreshControl.endRefreshing()
        tableView.isHidden = false

        self.semaphore.signal()
    }

Above are added this additional semaphores as additional check to ensure data consistency.

func startLoading(section: Int, animating: Bool = true) {
        self.semaphore.wait()
        print("startLoading section \(section)")

        tableView.beginUpdates()
        if animating {
            self.data[section] = .loadingSpinner
        }
        tableView.reloadSections([section], with: .none)
        tableView.endUpdates()

        self.semaphore.signal()
    }

func stopLoading(section: Int, model: Model) {
        self.semaphore.wait()
        print("stopLoading section \(section)")

        if section == 0 {
            guard debouncer1.currentWorkItem?.isCancelled != true else { return }
        } else if section == 1 {
            guard debouncer2.currentWorkItem?.isCancelled != true else { return }
        }

        tableView.beginUpdates()
        self.data[section] = model
        tableView.reloadSections([section], with: .none)
        tableView.endUpdates()

        self.semaphore.signal()
    }

 private func clearData() {
        self.semaphore.wait()
        print("clearData")

        data.removeAll()

        self.semaphore.signal()
    }

I think this extra semaphores should not be required as this is using serial queue so all request-response-reload are executed on serial queue. Maybe some problem is that I need to switch from serial queue to main queue in order to clear/add spinner-reload and then fill-data/reload table or section. But I think it should last shorter then next data replacement happens. I consider moving self.data.append(model1) to semaphore critical section in stopLoading() for reloadData() using self.data = data assignment in this critical section.

Example error that I have encountered:

Fatal Exception: NSInternalInconsistencyException Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (3) must be equal to the number of rows contained in that section before the update (5), plus or minus the number of rows inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of rows moved into or out of

 0x195bef098 +[_CFXNotificationTokenRegistration keyCallbacks]
3  Foundation                     0x1966b2b68 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:]
4  UIKitCore                      0x1c23ecc78 -[UITableView _endCellAnimationsWithContext:]
5  UIKitCore                      0x1c24030c8 -[UITableView endUpdates]
6  MyApplication                   0x104b39230 MyViewController.reload(section:) + 168
> that section (0 moved in, 0 moved out).

I have also seen errors on cellForRow function, this errors happen several times a week, app is rather used multiple times a day so it is hard to repeat this error. I have tried to send simple refreshing sockets from POSTman but they do not change underling data (row count) and I think is way everything works then ok.

UPDATE I have updated stopLoading() to stopLoading(data:) to have datasource update and tableView.reload on main queue. So all startLoading, stopLoading, reload methods are performed on DispatchQueue.main.async { }.

private func stopLoading(data: [Model]) {
        guard debouncer.currentWorkItem?.isCancelled != true else { return }

        self.semaphore.wait()
        print("stopLoading")

        self.data = data

        tableView.reloadData()
        activityIndicator.isHidden = true
        refreshControl.endRefreshing()
        tableView.isHidden = false

        self.semaphore.signal()
    }
Michał Ziobro
  • 10,759
  • 11
  • 88
  • 143
  • You shouldn't need any of those semaphores or debouncers. Always dispatch updates to your table view data source array on the main queue. The main queue is a serial dispatch queue, which means you can't update the data source at the same time as you are reloading the table from another update. – Paulw11 Sep 30 '19 at 09:52
  • But If I have simultaneous reloads ex. tap button (reload table), socket.io sections refresh, etc. then in the same time I get reloadData, reloadSection1(), etc. calls Then there are requests to api and time of this request response may vary. They even can be multiple reloadSection1() at short time intervals. reloadData makes several requests and then merge data from 2 or 3 request to display in tableview. meanwhile while refreshing I am replacing data source with loading spinner to display it in section instead of real data. So I don't think that it can be possible to do on Main Queue only – Michał Ziobro Sep 30 '19 at 10:05
  • What if reloadData() starts then hide table view and display spinner, in meantime reloadSection1() starts, replace section 1 with spinner then it finishes, then reloadDate gets section1 data (old) previously then merge with section2 data, update data source and then redisplay refreshing all table view. there will be more inconsistencies. – Michał Ziobro Sep 30 '19 at 10:07
  • The only way to resolve your threading issues is to perform everything in a single, serial dispatch queue. Since UI updates have to happen on the main queue, it is the obvious choice. Updates to the data source should be pretty quick, so I can't see an issue with performing all data source and table updates on the main queue – Paulw11 Sep 30 '19 at 10:07
  • Ok but in spite of this Debouncers (all at the same custom serial queue) this code startLoading(), stopLoading, startLoading(section:) stopLoading(section:) is executed on UI Thread like _uiThread(startLoading), _uiThread(stopLoading), etc. so update of data source and reload are switched to UI Main Thread (DispatchQueue.main.async { } underneath) – Michał Ziobro Sep 30 '19 at 10:10
  • iOS is non-preemptive. Once a given piece of work starts on a queue it runs until completion. So, if you dispatch some work on the main queue that, say, updates the array and reloads a section, nothing else can run on the main queue until all of that is done. Any other work that you dispatch into the main queue will be dispatched in turn – Paulw11 Sep 30 '19 at 10:10
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/200161/discussion-between-paulw11-and-michal-ziobro). – Paulw11 Sep 30 '19 at 10:13
  • Ok I have added UPDATE in my question. I think this can cause problem, as I have previously in reloadData() something like self.data.append(sectionNData) several times after each request response, then at the end stopLoading() with just reloadData(). Now I try to gather this section1Data ... section3Data in array and pass them to stopLoading(data: data) and then do data source update and table.reloadData() in stopLoading() which is executed on main thread – Michał Ziobro Sep 30 '19 at 10:15

0 Answers0