Issue #904

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

In the example above, 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

Questions on Swift Forum

Which Task block the main thread?

Swift 5.7 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.

Determining whether an async function will run on the main actor

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.

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.

Flavors of Task

Explore structured concurrency in Swift

An asynchronous call

Meet async/await in Swift

Once it’s running, an async function can suspend. When it does, it gives up control of the thread. But rather than giving control back to your function, it instead gives control of the thread to the system. When that happens, your function is suspended too

Now, this is also true when you use completion handlers. But because you don’t have all the ceremony and indentation they entail in async/await code, the await keyword is how you notice that a block of code doesn’t execute as one transaction. The function may suspend, and other things may happen while it’s suspended between the lines of the function.

More than that, the function may resume onto an entirely different thread

Visualize and optimize Swift concurrency

Work can be moved into the background by putting it in a normal Actor or in a detached task.

Eliminate data races using Swift Concurrency

Non-isolated async code always runs on the global cooperative pool.

Concurrency

When calling an asynchronous method, execution suspends until that method returns. You write await in front of the call to mark the possible suspension point. This is like writing try when calling a throwing function, to mark the possible change to the program’s flow if there’s an error.

Read more