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()
}
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 becauseUIViewController
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:
You have to check inside fetchNotes
, not from where you called it.
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.