Today I refactored a ViewModel for a SwiftUI view to structured concurrency. It fires a network request and when the request comes back, updates a @Published
property to update the UI. Since I use a Task
to perform the network request, I have to get back to the MainActor
to update my property, and I was exploring different ways to do that. One straightforward way was to use MainActor.run
inside my Task
, which works just fine. I then tried to use @MainActor
, and don't quite understand the behaviour here.
A bit simplified, my ViewModel would look somewhat like this:
class ContentViewModel: ObservableObject {
@Published var showLoadingIndicator = false
@MainActor func reload() {
showLoadingIndicator = true
Task {
try await doNetworkRequest()
showLoadingIndicator = false
}
}
@MainActor func someOtherMethod() {
// does UI work
}
}
I would have expected this to not work properly.
First, I expected SwiftUI to complain that showLoadingIndicator = false
happens off the main thread. It didn't. So I put in a breakpoint, and it seems even the Task
within a @MainActor
is run on the main thread. Why that is is maybe a question for another day, I think I haven't quite figured out Task
yet. For now, let's accept this.
So then I would have expected the UI to be blocked during my networkRequest - after all, it is run on the main thread. But this is not the case either. The network request runs, and the UI stays responsive during that. Even a call to another method on the main actor (e.g. someOtherMethod
) works completely fine.
Even running something like Task.sleep()
within doNetworkRequest
will STILL work completely fine. This is great, but I would like to understand why.
My questions:
a) Am I right in assuming a Task
within a MainActor
does not block the UI? Why?
b) Is this a sensible approach, or can I run into trouble by using @MainActor
for dispatching asynchronous work like this?
await
is a yield point in Swift. It's where the current Task releases the queue and allows something else to run. So at this line:your Task will let go of the main queue, and let something else be scheduled. It won't block the queue waiting for it to finish.
This means that after the
await
returns, it's possible that other code has been run by the main actor, so you can't trust the values of properties or other preconditions you've cached before theawait
.Currently there's no simple, built-in way to say "block this actor until this finishes." Actors are reentrant.