iosswiftconcurrency

Running Time-Consuming Tasks on @MainActor: Should I Be Concerned About UI Responsiveness?


I always thought that using Task would automatically run time-consuming tasks on a background thread, keeping the UI responsive.

However, in the following code, I noticed that my fetchNotes function (which is time-consuming) still runs on the main UI thread because UIViewController is marked as @MainActor.

Is this something to be concerned about? Will it affect the UI's responsiveness if a time-consuming task runs on the main UI thread?

Interestingly, during my tests, even though fetchNotes takes a few seconds to complete, my UI doesn’t freeze. Why isn’t the UI freezing when the main UI thread is handling a time-consuming operation?

Should I ever consider using Task.detached?

Here's my code snippet.

class MainViewController: UIViewController {
    private func fetchNotesAsync() {
        print(">>>> fetchNotesAsync \(Thread.isMainThread)")    // true

        Task {
            print(">>>> Task \(Thread.isMainThread)")   // true
            
            let noteWrappers = await fetchNotes()

            ...
        }
    }

    private func fetchNotes() async -> [NoteWrapper] {
        // Executing firebase query.getDocuments() to retrieve remote documents.
        // query.getDocuments() is a firebase library async function
        
        let querySnapshot = try await query.getDocuments()
    }

Solution

  • tl;dr

    Your code snippet is fine, and the main actor will not be blocked.


    You said:

    I always thought that using Task would automatically run time-consuming tasks on a background thread, keeping the UI responsive.

    Let us first outline what is going on:

    So, no, Task does not “run time-consuming tasks on a background thread”. (In fact, again, Task {…} will actually create a new top-level task on behalf of the current actor.) The reason that fetchNotes does not block the main actor is simply because it performs an await of an async function, getDocuments. So, while getDocuments runs, the main actor is free to do other things, namely to keep the UI responsive.

    You noted:

    However, in the following code, I noticed that my fetchNotes function (which is time-consuming) still runs on the main UI thread because UIViewController is marked as @MainActor.

    Yep, that is correct.

    But an aside regarding your code snippet: It does not really check to see fetchNotes runs on the main thread. You are checking that fetchNotesAsync runs on the main thread (which makes sense because that is isolated to the main actor). You are also checking that its Task {…} runs on the main thread (which makes sense, because fetchNotesAsync is, too). But neither of those has any bearing on which thread fetchNotes runs. As you said, that is dictated solely by the fact that it is an isolated function of a class isolated to the main actor.

    If you really wanted to check to see if fetchNotes was on the main actor, you need to do that inside fetchNotes, itself:

    private func fetchNotes() async -> [NoteWrapper] {
        MainActor.assertIsolated()
    
        …
    
        let querySnapshot = try await query.getDocuments()
    
        …
    }
    

    So, two observations:

    1. You have to check inside fetchNotes, not from where you called it.

    2. You should not use Thread API from Swift concurrency. The compiler is allowed to do all sorts of fancy optimizations that may make Thread-based checks invalid. In fact, in Swift 6 will not even permit this test within an asynchronous context. Use assertIsolated to test whether you are on the main actor or not.

    Now, setting these two points aside, it turns out that fetchNotes really is isolated to the main actor. But, the fact that getDocuments might take a little time is not a concern, but because you await it; the main actor is free to go on to do other things while fetchNotes is suspended, awaiting the results of getDocuments.

    And the context used by getDocuments has nothing to do with the actor isolation of fetchNotes. Unlike GCD, where the caller’s context often dictated what queue a called function used, in Swift concurrency, the question is how getDocuments was implemented, and the actor isolation of fetchNotes is immaterial.

    But, bottom line, you are correct that fetchNotes is isolated to the main actor, because it is isolated member of this UIViewController subclass. But getDocuments will not block the main actor.

    Is this something to be concerned about? Will it affect the UI's responsiveness if a time-consuming task runs on the main UI thread?

    Nope. This is the beauty of Swift concurrency: If you merely await other async functions, then the caller’s concurrency context will not be blocked.

    You continued:

    Interestingly, during my tests, even though fetchNotes takes a few seconds to complete, my UI doesn’t freeze.

    Yep, the main actor is not blocked, and therefore the UI will not freeze. Yes, fetchNotes will suspend until getDocuments returns a value, but the main actor, itself, is not blocked.

    And you said:

    Why isn’t the UI freezing when the main UI thread is handling a time-consuming operation?

    Because you await query.getDocuments() function, the main actor is not blocked.

    Should I ever consider using Task.detached?

    This is not a use-case for a detached task.