swiftswiftuiswift-concurrency

Complete Concurrency check enabled and how to resolve warnings (contd.)


Continuation of Complete Concurrency check enabled and how to resolve warnings

Here is the implementation of the fileImporter... the execution takes too much time and memory in .task() in ContentView because that code is running on the main thread. Question is how to detach it from the main thread to a background thread and come back to the main thread after the operation safely.

Using the MainActor on ContentView to ensure we don't have any data races (concurrency checks under build settings to complete in Xcode) and within the .task(id: selectedFile) importData() is called. importData() is in the extension ContentView

importData() calls readMetaData and then loops to import each item

@MainActor
struct ContentView: View {
    @Environment(\.modelContext) private var modelContext

    @State private var disable: Bool = false
    @State private var value: Double = 0
    @State var progress = ProgressItem()
    @State private var importData: Bool = false
    @State private var selectedFile: URL? = nil

    var body: some View {
        VStack {
            Button {
                disable = true
                importData = true
            } label: {
                Text("Press")
            }
            .task(id: selectedFile) {
                guard importData else {return}
                if let selectedFile = selectedFile {
                    let data = try! String(contentsOf: selectedFile, encoding: .utf8)
                    await importData(data: data)
                    importData = false
                }
            }
            .fileImporter(isPresented: $importData, allowedContentTypes: [UTType.plainText], allowsMultipleSelection: false) { result in
                do {
                    guard let selectedFile: URL = try result.get().first else { return }
                    self.selectedFile = selectedFile
                    importData = true
                    //print(selectedFile)
                    progress.message = ""
                    progress.progress = 0.0
                } catch {
                    print(error.localizedDescription)
                }
            }
            Text("\(value)")
            
        }
    }
}

extension ContentView {
    
    func fetchValue() async -> Double {
        //long running task
        try? await Task.sleep(nanoseconds: 60)
        return 2.0
    }
    
    func importData(data: String) async {
        let actor = DataHandler(modelContainer: modelContext.container)
        // read metadata from file and identify number of items
        await actor.readMetadata(data: data)
        
        //loop through number of items from metadata and import each item
        let items = await actor.getItems()
        
        for item in items {
            await actor.importItemsFrom(data: data)
        }
        
        print("... in importFile...")
    }
}

@ModelActor
actor DataHandler {
    private var metadata = [String]()
    private var nItems: Int = 0
    
    func insert<T: PersistentModel>(_ data: T) {
        modelContext.insert(data)
        try? modelContext.save()
    }

    func save() {
        try? modelContext.save()
    }
    
    func getNumItems() -> Int {
        return nItems
    }
    
    func getItems() -> [String] {
        return metadata
    }
    
    func readMetadata(data: String) {
        //reads metadata and saves metadata locally in metadata
        //increment nItems

    }
    
    func importItemsFrom(data: String) {
        // reads lines from file and persists to database using insert and save
    }
}

Solution

  • A couple of things:

    1. Getting this off the main thread.

      There is a bug in ModelActor, as discussed in Swift forums SwiftData does not work on a background Task even inside a custom ModelActor. As they discussed there, I found that your DataHandler will run on the main thread, even though it is its own actor. (!!!) While that is an older discussion on that forum, I still experience this behavior as of Xcode 15.3 and Swift 5.10.

      As discussed in that other thread, you can manually detach the task (but then you probably would add cancelation handler):

      func importData(data: String) async throws {
          let container = modelContext.container
      
          let task = Task.detached {
              let modelActor = DataHandler(modelContainer: container)
      
              …
          }
      
          try await withTaskCancellationHandler {
              try await task.value
          } onCancel: {
              task.cancel()
          }
      }
      

      Even easier, you can make this nonisolated:

      nonisolated func importData(data: String, into container: ModelContainer) async throws {
          let actor = DataHandler(modelContainer: container)
      
          …
      }
      

      Note that I am passing the ModelContainer. That is because the modelContext is not Sendable. That obviously entails an update at the calling point.

      try await importData(data: …, into: modelContext.container)
      

      There are other ways to get this off the main thread, but this is the basic idea. Make sure, despite the appearances of using an actor, that it really is running on a background thread. You can add breakpoints in your code and look at which thread it is using.

    2. In my experiment, fixing the first point addressed a significant performance issue, making 3× faster. It also eliminated the discrepancy that I was seeing between running it from the Xcode debugger and from Instruments.

    3. You have not shared a complete MRE, but you have shared a function, insert<T: PersistentModel>(), that suggests that you are saving after every insertion. You might consider using a transaction or otherwise batching your inserts. In my example (10,000 insertions, only saving every 1,000th), that made it 19× faster.

    4. Minor observations, but I would avoid calling String(contentsOf:encoding:) from the main actor. If your files are tiny, that might not be not an issue, but with large files that might result in an observable hitch in the UI if you do that on the main actor.

      Personally, I might even avoid loading the whole file into memory at one time. E.g., this is a non-blocking way of reading the individual lines asynchronously, resulting in a smaller memory footprint:

      @ModelActor
      actor DataHandler {
          func importItems(from fileURL: URL) async throws {
              var count = 0
      
              for try await line in fileURL.lines {
                  count += 1
                  if count % 1000 == 0 {
                      print(count)
                      try modelContext.save()
                  }
                  let item = Item(…)
                  modelContext.insert(item)
              }
      
              try modelContext.save()
          }
      }
      

      You did not share your file format, so I do not even know if this is applicable in your scenario, but consider if you can process the file as it is consumed, rather than loading it all in memory at one time (especially if the files are potentially large).