Where does code actually run after an await: what do @MainActor, MainActor.run and a nonisolated async function guarantee about the thread?

10.9.4 · L3 · Interview

Predict first

Where does each of these run?

import Foundation

func isMain() -> Bool { Thread.isMainThread }
@MainActor func onMain() -> String { "annotated: main=\(isMain())" }
func nonisolatedAsync() async -> String { "nonisolated async: main=\(isMain())" }

func background() async -> String {
    let a = await onMain()
    let b = await MainActor.run { "MainActor.run: main=\(isMain())" }
    return a + " | " + b
}
print(await Task.detached { await background() }.value)
print(await Task.detached { await nonisolatedAsync() }.value)

@MainActor func fromMain() async {
    print("called from the main actor ->", await nonisolatedAsync())
}
await fromMain()
  • annotated: main=true | MainActor.run: main=true / nonisolated async: main=false / called from the main actor -> nonisolated async: main=false
  • annotated: main=true | MainActor.run: main=true / nonisolated async: main=true / called from the main actor -> nonisolated async: main=true
  • annotated: main=false | MainActor.run: main=true / nonisolated async: main=false / called from the main actor -> nonisolated async: main=true

Mental model. A @MainActor function is a job that can only be done in the head office. A nonisolated async function is a freelancer: wherever you call from, they work in their own place.

Isolation decides where code executes. A @MainActor function always runs on the main thread, however it is called: the check prints main=true for it, both when called from a detached task and via MainActor.run, which hops to the main actor, runs the closure there and returns. Calling it from elsewhere needs an await, which is the hop. Use @MainActor on UI-facing types, view models and anything that touches UIKit or SwiftUI state, and keep expensive work off it.

The surprise is the other direction. A nonisolated async function runs on the global concurrent executor, not on its caller’s actor: even when fromMain() calls it from the main actor, it reports main=false, and the work leaves the main thread. That is helpful for heavy work, but it also means code you expected to stay on the main actor (and touching non-Sendable state) is now crossing isolation. Swift 6.2 adds nonisolated(nonsending) and a @concurrent attribute, plus an upcoming-feature flag that makes nonisolated async functions stay on the caller’s actor by default, so the behaviour depends on your language mode and settings: check the target’s build settings instead of assuming. (The helper isMain() is a plain function because Thread.isMainThread is unavailable directly in async code.)

MainActor code is on the main thread; a nonisolated async function runs on the global executor even when called from main.

Common traps

  • Assuming an async function called from the main actor stays on it. A nonisolated one does not (unless your settings change the default).
  • Calling DispatchQueue.main.async inside @MainActor code “to be safe”. It adds a needless hop and an ordering change.

They might follow up with

  • How do I run heavy work off the main actor on purpose? A nonisolated async function (or @concurrent in Swift 6.2), called with await.
  • What about Task { @MainActor in … }? It starts a task whose body runs on the main actor, the explicit form of what Task { } inherits in main-actor code.

Related questions

  • 10.9.5 What does the Swift 6.2 default actor isolation setting change, and how does it affect a global variable or…
  • 10.9.1 Which accesses to an actor compile without await, which do not compile at all, and what do nonisolated…
  • 27.4.2 In what order do these prints appear when a main-actor function creates a Task, yields, and starts a detached…
  • 10.4.2 How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?
  • 10.5.2 How do you migrate an existing app to Swift 6 and strict concurrency without breaking it?

← How do you define your own global actor, what does annotating a type…   What does the Swift 6.2 default actor isolation setting change, and… →