Which Task block the main thread?

Consider this code where we have an ObservableObject with fetch1 and async fetch2, and a fetch inside ContentView

Here the observation in Xcode 14

  • ViewModel.fetch1: run on main thread
  • ViewModel.fetch2: run on cooperative thread pool
  • ContentView.fetch: run on main thread

On Xcode 13, ViewModel.fetch2 actually blocks the main thread too

import SwiftUI
import CoreData
import Combine

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

struct ContentView: 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")!
        let data = try! Data(contentsOf: url)
        self.string = String(data: data, encoding: .utf8) ?? ""
    }
}

My understanding is, the work Data(contentsOf is synchronously blocking so it blocks whatever thread it is executed on.

  • ContentView.fetch is running on the main actor, since we have StateObject declaration in a SwiftUI view, which turns the whole view to be run on the main actor.
  • ViewModel.fetch1 is not marked as async, so it blocks the thread that Task is spawned. Task inherits the async context where is spawned from, in this case, the main actor
  • ViewModel.fetch2 is an async function. Although the Task is spawned in the context of the main actor, it has suspension point in the await, and Swift concurrency uses the cooperative thread pool to execute the work

I would love to better understand these.
I found the context inheritance a bit implicit, and I also wonder when/how Swift decices to use the cooperative thread pool. Since ultimately our goal is to try perform heavy operations of the main thread, and do UI On the main thread, reliably.

1 Like

There is no await in fetch2, so there is no suspension point inside fetch2 where it can switch executors. However, it can switch on entry, since its caller must use await. It did not ever switch prior to 5.7, but it does in 5.7 if it is called in an actor context.

Check out SE-0338, “ Clarify the Execution of Non-Actor-Isolated Async Functions”. It was implemented in Swift 5.7. It describes the pre-5.7 behavior thus:

In the current implementation, calls and returns from actor-isolated functions will continue running on that actor's executor. As a result, actors are effectively "sticky": once a task switches to an actor's executor, they will remain there until either the task suspends or it needs to run on a different actor.

So, pre-5.7, if fetch2 is called in a @MainActor context, it'll run on @MainActor.

Here is the 5.7 behavior:

async functions that are not actor-isolated should formally run on a generic executor associated with no actor. Such functions will formally switch executors exactly like an actor-isolated function would: on any entry to the function, including calls, returns from calls, and resumption from suspension, they will switch to a generic, non-actor executor. If they were previously running on some actor's executor, that executor will become free to execute other tasks.

So in Swift 5.7 (which shipped with Xcode 14.0), if fetch2 is called in a @MainActor context, it will immediately schedule itself to run elsewhere than @MainActor, and @MainActor will be able to concurrently run other jobs.

2 Likes

You probably missed my message on Slack earlier today, but I posted the exact same snippet earlier today... https://forums.swift.org/t/determining-whether-an-async-function-will-run-on-the-main-actor/