Will
Will

Reputation: 5490

Can't ensure thread safety despite @MainActor

I have a setup where I have an async function which fetches items from an API, and then triggers a snapshot rebuild for a UICollectionView.

When the UICollectionView detects that less than 5 rows remain in the data set, it called updateData() to fetch the next 10 items.

The problem I am facing is the collectionView(willDisplay cell) method can execute twice before updateData has finished, leading to two API requests using the same cursor, which causes UICollectionViewDiffableDataSource to crash as there are now duplicate identifiers.

My setup looks like this:

@MainActor
internal func updateItems() async throws {
    let newItems = try await NetworkService.shared.getItems(limit: 10, cursor: cursor)
    self.cursor = newItems.last
    for item in newItems {
        self.items.insert(item)
    }
    self.itemIDs.append(contentsOf: newItems(\.id))
    self.buildSnapshot()
}

func buildSnapshot() {
    var snapshot = NSDiffableDataSourceSnapshot<Section, UUID>()
    snapshot.appendSections([.allItems])
    snapshot.appendItems(self.items)
    self.dataSource.apply(snapshot)
}

func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
    if (self.itemIDs.count - indexPath.row) < 5 {
        Task(priority: .userInitiated) {
            try await self.updateItems()
        }
    }
}

So if try await self.updateItems() is called twice too close together, things crash.

Can anyone offer any advice to solve this issue?

I have tried using a variable fetchInProgress and checking that within updateItems with no luck. I have tried annotating the function with @MainActor (but I don't think I want the whole thing to run on the main queue anyhow), and tried using { @MainActor in with the Task.

Upvotes: 1

Views: 149

Answers (1)

James
James

Reputation: 56

If your goal is to prevent duplicate Tasks from firing I would try:

  1. In your class add a property:
private var updateTask: Task<Void, Error>?
  1. Then in your collectionView function:
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
    if (self.itemIDs.count - indexPath.row) < 5 && updateTask == nil { 
        updateTask = Task(priority: .userInitiated) {
            try await self.updateItems()
            self.updateTask = nil
        }
    }
}

This way, your task will only be non-nil in the case that it has been started and not finished. If we find it to be non-nil then we do not create it again, thus avoiding duplicate tasks.

This solution I modified from here, which I'd recommend taking a look at as well.

Also, here is a simple playground example I came up with:

class MyClass {
    private var updateTask: Task<Void, Error>?
    func updateItems() async throws {
        // Waits 1 second
        try await Task.sleep(nanoseconds: 1_000_000_000)
        print("Hello World")
    }
    
    func doUpdate() {
        if updateTask == nil {
            updateTask = Task(priority: .userInitiated) {
                try await self.updateItems()
                self.updateTask = nil
            }
        }
    }
    
    func updateTooOften() {
        for _ in 0..<3 {
            doUpdate()
        }
    }
}

let myClass = MyClass()
myClass.updateTooOften() // prints "Hello World" once

Here doUpdate() is called twice more before the task is finished, but we only execute the task once and see a single "Hello World" printed.

Upvotes: 0

Related Questions