Determining whether an async function will run on the main actor

Hi,

A few days ago I saw this Tweet which features the following code:

struct ContentView2: View {
    @State var string = ""
    @StateObject var vm = ViewModel()
    
    var body: some View {
        VStack {
            Button {
                Task {
                    await vm.fetch1()
                }
                
                Task {
                    await vm.fetch2()
                }
                
                Task {
                    await fetch()
                }
            } label: {
                Text ("Fetch")
            }
            
            Text(string)
            Text(vm.string)
        }
    }
    
    private func fetch() async {
        let url = URL(string: "https://google.com")!
        print("F3: \(Thread.isMainThread)")
        let data = try! Data(contentsOf: url)
        print("F3: \(Thread.isMainThread)")
        self.string = String(data: data, encoding: .utf8) ?? ""
    }
}

class ViewModel: ObservableObject {
    @Published var string = ""
    
    func fetch1() {
        let url = URL(string: "https://google.com")!
        print("F1: \(Thread.isMainThread)")
        let data = try! Data(contentsOf: url)
        print("F1: \(Thread.isMainThread)")
        string = String(data: data, encoding: .utf8) ?? ""
    }
    
    func fetch2() async {
        let url = URL(string: "https://google.com")!
        print("F2: \(Thread.isMainThread)")
        let data = try! Data(contentsOf: url)
        print("F2: \(Thread.isMainThread)")
        string = String(data: data, encoding: .utf8) ?? ""
    }
}

This code results in the following output when compiled with Xcode 14:

F1: true
F1: true
F3: true
F2: false
F2: false
F3: true

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.

6 Likes

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.

26 Likes

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!

1 Like

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

Yes, every time a @MainActor function awakes from suspension, it will resume on the main actor. You do not need to manually switch back.

3 Likes

Great, thanks!

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.)

3 Likes

Could you elaborate on // <-- Inferred from @StateObject var part?

I thought having a @StateObject inside a struct as a property has nothing to do with the @MainActor inference on the struct, or is it?

As per SE-0316 (Global Actors):

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.

5 Likes

Just noting that as of iOS 18 View is annotated with @MainActor, which in turn makes the inference explicit.

@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?

Thanks

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.

I just tested this myself

2 Likes

Ah! Thanks Donny! Just tried myself too and you're completely right. Thanks for answering.