5

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?

isherwood
  • 58,414
  • 16
  • 114
  • 157
BlackWolf
  • 5,239
  • 5
  • 33
  • 60
  • Do you understand the distinction between the main *thread* and the main *dispatch queue*? The executor of the main actor is the latter, not the former. – Sweeper Mar 29 '22 at 19:32
  • I think I do at least, although to be honest I never had to care about the distinction so far. In the end I assume(d) `doNetworkRequest()` runs on the main thread - be it on the main queue or another queue - and therefore nothing else is able to run on the main thread as long as it is running. If you can shed some light on why it is not working like that exactly, I would be very happy! – BlackWolf Mar 29 '22 at 20:55

1 Answers1

8

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:

        try await doNetworkRequest()

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 the await.

Currently there's no simple, built-in way to say "block this actor until this finishes." Actors are reentrant.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • thank you for the answer. I think that makes sense, I was under the impression `await` would block the current thread until it is done. Seems not to be the case. Does that mean that any awaited `async` method is executed on a random Thread and then returns to the calling thread when it is done? – BlackWolf Mar 29 '22 at 20:42
  • 1
    No, it will be executed on the main thread (since this is the main actor) until it yields. This means, for example, if the task is CPU-intensive (rather than I/O-bound), it will block the main thread. You should not use async/await to handle CPU-intensive operations. (In the future this will be better supported.) The `await` keyword does not actually do the yielding. It is a notice to the programmer that somewhere inside that call there may be a yield. (This is similar to how `try` does not actually do anything; it just warns the programmer that there may be a throw in this call.) – Rob Napier Mar 29 '22 at 21:07