Where does code actually run after an await: what do @MainActor, MainActor.run and a nonisolated async function guarantee about the thread?
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
asyncfunction called from the main actor stays on it. A nonisolated one does not (unless your settings change the default). - Calling
DispatchQueue.main.asyncinside@MainActorcode “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
@concurrentin Swift 6.2), called withawait. - What about
Task { @MainActor in … }? It starts a task whose body runs on the main actor, the explicit form of whatTask { }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… →