What I'm trying to verify is whether my reasoning for this is correct.
F1 is blocking and running on the main thread because we're awaiting an non-async function. I expect that the compiler will just call this synchronously given that it also shows a warning that tells me I'm doing something weird.
F2 is not blocking because it's an async function that's not isolated to any actor. So at runtime the system will decide that the Task / function that we call this function from can be suspended, and that the async function can run off of the main thread, freeing up the main thread to do other work until our function returns.
F3 is blocking because the fetch function in our View is both async and MainActor isolated because the view itself is implicitly MainActor annotated due to its @StateObject.
I know that the code itself doesn't make sense and doesn't follow best practices. I'm simply looking for confirmation on whether or not my reasoning about what runs where is correct.
Your reasoning is essentially correct. I'll expand on it a bit more, but you're on the right track.
The essential rule to know is:
Synchronous functions always run on the thread they were called from
Asynchronous functions always choose where they run, no matter where they were called from.
If it is isolated to an actor, it runs on that actor
Otherwise, it runs on the default executor, which is not the main thread.
I'll note that there are actually five async functions in this code; the two that are declared explicitly, and the three closures passed to the Task initializer. As you correctly noted, there are some implicit @MainActor annotations being inferred. Let's start by making those annotations explicit:
@MainActor // <-- Inferred from @StateObject var
struct ContentView2: View {
@State var string = ""
@StateObject var vm = ViewModel()
var body: some View {
VStack {
Button {
Task { @MainActor in // <-- Inherited from @MainActor struct
await vm.fetch1()
}
Task { @MainActor in // <-- Inherited from @MainActor struct
await vm.fetch2()
}
Task { @MainActor in // <-- Inherited from @MainActor struct
await fetch()
}
} label: {
Text ("Fetch")
}
Text(string)
Text(vm.string)
}
}
@MainActor // <-- Inherited from @MainActor struct
private func fetch() async {
// elided for brevity
}
}
class ViewModel: ObservableObject {
@Published var string = ""
func fetch1() {
// elided for brevity
}
func fetch2() async {
// elided for brevity
}
}
With these annotations made explicit, it's easier to explain what's happening.
All three Task closures are @MainActor isolated, so any synchronous code within them runs on the main thread.
fetch1 is a synchronous function, so the await does nothing when you call it. The task it's called from is @MainActor-isolated, so fetch1() also runs on the main thread in this example. (If you were to call it from a background thread, it would run in the background.)
fetch2 is an async function, so it decides where it runs. Since it's not isolated to an actor, it runs on the default executor, which on Apple platforms is a pool of threads that runs in the background, and thus not on the main thread. Even though it was called from the main thread, it still runs in the background.
fetch3 is an async function, so it decides where it runs. Since it is @MainActor isolated, it always runs on the main thread, no matter where it was called from.
Thank you so much for expanding my reasoning a little! Especially this is useful
Since it's not isolated to an actor, it runs on the default executor, which on Apple platforms is a pool of threads that runs in the background, and thus not on the main thread. Even though it was called from the main thread, it still runs in the background.
It's something I had observed (the function runs away from main because it doesn't have to be run on main) I wasn't quite sure why I was observing that so this is great to know. Thanks again!
In this example, fetch() is isolated to @MainActor, so it runs on @MainActor... but what if the function also has suspension points within it? Does it always return back to the actor it was called from? In this example, do I need to switch back onto the @MainActor?
@MainActor
private func fetch() async {
let value = try await doSomething()
// <-- here?
}
@MainActor
private func fetch() async {
let value = try await doSomething()
await MainActor.run(body: { // <-- here?
})
}
I still don't understand why F2 runs on a separate actor.
Doesn't this contradict the idea that asynchronous function only suspends when it reaches a suspension point? Since fetch2 doesn't actually suspend, shouldn't it behave exactly like a synchronous function? That's how I read this section of the proposal:
While it’s calling a synchronous function, of course, it can’t give up its thread. In fact, asynchronous functions never just spontaneously give up their thread; they only give up their thread when they reach what’s called a suspension point.
There's no contradiction here because it's not about whether fetch2 suspends… it's about how it starts running in the first place. You're right that once it's running, fetch2 never suspends, so it acts much like a synchronous function.
The key difference here is that with synchronous functions, a called function always runs on the caller's thread. Async functions don't do that; they always hop to the appropriate executor before execution. So even if I have an async function that never suspends, it will still execute that non-suspending work in a different context than the caller.
(Unless both async functions are isolated to the same actor, in which case they both live in the same context. But again, this just emphasizes the point that the called function decides where it will execute. It never just inherits the caller's context.)
A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:
@propertyWrapper
struct UIUpdating<Wrapped> {
@MainActor var wrappedValue: Wrapped
}
struct CounterView { // infers @MainActor from use of @UIUpdating
@UIUpdating var intValue: Int = 0
}
Since @StateObject has a MainActor-isolated wrapped value, a struct that contains a @StateObject property is itself inferred to be isolated to the main actor. A couple months ago I posted a pitch to disable this particular inference in Swift 6, as it seems to be quite unexpected to the community at large.
@bjhomer I just tried the following code in Xcode 16 and noticed that the async function test from my class is called on the main thread, even though I don't think it's isolated to any actor. It seems to contradict what you say in your first post (and the associated example).
import SwiftUI
struct ContentView: View {
var body: some View {
Button("Call function") {
Task {
let generator: ImageGenerator = .init()
await generator.test()
}
}
}
}
class ImageGenerator {
func test() async {
}
}
Maybe I've misunderstood how things work in this case? Or has something changed recently?
I think the thread switch hasn't happened yet at the time your breakpoint fires and / or the compiler might have decided that it's not going to add a thread hop for an empty function.
If you add a print statement to the function and break on that you should see that you did move away from the main thread.