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.

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

12 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