iosswiftswift-concurrency

Is Task.detached a good and correct way to offload heavy work from the UI thread to keep the UI smooth?


I have two use cases: offloading heavy work from the UI thread to keep the UI smooth.

Perform searching while user is typing.

extension MoveNoteViewController: UISearchBarDelegate {

    // Busy function.
    private func filterNotes(_ text: String) async -> [Note] {
        let filteredNotes: [Note] = await Task.detached { [weak self] in
            guard let self else { return [] }
            
            let idToFolderMap = await idToFolderMap!
            
            if text.isEmpty {
                return await notes
            } else {
                return await notes.filter { [weak self] in
                    guard let self else { return false }
                    
                    let emoji = $0.emoji
                    let title = $0.title
                    var folderName: String? = nil
                    if let folderId = $0.folderId {
                        folderName = idToFolderMap[folderId]?.name ?? ""
                    }
                    
                    return
                        emoji.localizedCaseInsensitiveContains(text) ||
                        title.localizedCaseInsensitiveContains(text) ||
                        (folderName?.localizedCaseInsensitiveContains(text) ?? false)
                }
            }
        }.value
        
        return filteredNotes
    }
    
    @MainActor
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        let text = searchText.trim()
        
        if text.isEmpty {
            applySnapshot(snapshot: getSnapshot(notes: notes))
        } else {
            Task {
                let filteredNotes = await filterNotes(text)
                
                if searchBar.text?.trim() == text {
                    applySnapshot(snapshot: getSnapshot(notes: filteredNotes))
                }
            }
        }
    }
}

Perform list of file iteration I/O

// Busy function.

private static func fetchRecentLocalFailedNoteCountAsync() async -> Int {
    return await Task.detached { () -> Int in
        let fileManager = FileManager.default
        
        guard let enumerator = fileManager.enumerator(at: UploadDataDirectory.audio.url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return 0 }
        
        var count = 0
        for case let fileURL as URL in enumerator {
            if !RecordingUtils.isValidAudioFileExtension(fileURL.pathExtension) {
                continue
            }
            
            if let fileCreationTimestamp = FileUtils.getFileCreationTimestamp(from: fileURL) {
                if await fileCreationTimestamp > MainViewController.createdTimeStampConstraint {
                    count += 1
                }
            }
        }
        
        return count
    }.value
}

I was wondering, am I using Task.detached in a correct and good practice way?


Solution

  • To offload some CPU intensive work from the main thread, there are many ways to accomplish this.

    One approach, is defining a global actor specifically for running CPU intensive work. Then, associate CPU intensive functions with it:

    Your custom minimalistic Global Actor:

    @globalActor actor MyGlobalActor: GlobalActor {
        static let shared = MyGlobalActor()
    }
    

    CPU intensive synchronous function:

    @MyGlobalActor
    func compute(input: sending Input) -> sending Output {
       ...
    }
    

    Note: you may require Input and Output to conform to Sendable.

    Then, use it like

    @MainActor
    func foo() async throws {
        let result = await compute(input: 100.0)
        print(result)
    }
    

    Why does this work:

    "By default, actors execute tasks on a shared global concurrency thread pool."

    The MyGlobalActor global actor above, does not specify a custom executor.

    See: https://developer.apple.com/documentation/swift/actor

    Update

    Another approach is, to associate the CPU intensive function with an actor instance, which then becomes actor isolated:

    func compute(
        isolated: isolated any Actor = #isolation,
        input: sending Input
    ) -> sending Output {
       ...
    }
    

    You then call it like:

    Task { @MyGlobalActor in 
        let result = compute(input: 100.0)
        print(result)
    }
    

    The actor instance will be implicitly passed as an argument. The compute() function executes on MyGlobalActor.

    Note, that you don't need to use a Task and a closure which specifies the actor. It's sufficient, that the compiler can infer an actor. Note also, that the compiler requires to infer the actor, otherwise it ungracefully fails to compile (I hope this will be improved in the future).

    One can make the parameter isolated also optional. In this case, the compiler wouldn't crash when there is no actor. However, I would rather prefer the compiler to crash, to indicate there's an actor missing, than running the code on an unspecified isolation domain, which would missing the original goal.