skip to Main Content

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()
    }

2

Answers


  1. The non-async code in the Task { ... } and fetchNotes are indeed running on the main actor, but query.getDocuments is not, assuming it is Query.getDocuments.

    getDocuments is not declared to be isolated to any actor, and it is also not declared in an actor-isolated type. Therefore, getDocuments is nonisolated. Nonisolated async methods are always run on some thread in the cooperative thread pool.

    If you use Task.detached, the synchronous code in the Task will be run on a thread in the cooperative thread pool, but not the synchronous code in fetchNotes because fetchNotes is declared in a class isolated to the main actor. You need to additionally declare fetchNotes as nonisolated for the synchronous code in its body to run off of the main actor.

    Login or Signup to reply.
  2. 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:

    • The fetchNotesAsync is isolated to the main actor, because it is a method of a class happens to be isolated to the main actor.

    • The Task in fetchNotesAsync is isolated to the main actor, too, because Task {…} creates a top-level task on behalf of the current actor (i.e., fetchNotesAsync’s actor).

    • The fetchNotes also is isolated to the main actor, because it is a method of a class happens to be isolated to the main actor.

      Note: The fact that this was called from Task {…} in fetchNotesAsync (which also happens to be isolated to the main actor) is completely irrelevant. The fetchNotes is isolated to the main actor solely by the fact that it was an isolated function of actor-isolated type, a UIViewController subclass. Actor isolation is dictated by how the function was defined, not by who called it.

    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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search