Where do a nonisolated async function, @concurrent, @MainActor and Task run when called from the main actor?
Predict first
What does this print in the default mode?
func here() -> String { Thread.isMainThread ? "main" : "background" }
nonisolated func plain() async -> String { here() }
@concurrent func hop() async -> String { here() }
@MainActor func onMain() async -> String { here() }
actor Counter {
var n = 0
let id = "ctr"
nonisolated func tag() -> String { "tag-" + id }
func bump() -> Int { n += 1; return n }
}
@MainActor func demo() async {
print("start:", here())
print("nonisolated async:", await plain())
print("@concurrent:", await hop())
print("@MainActor:", await onMain())
let c = Counter()
print(c.tag(), c.id, await c.bump())
await Task.detached { print("detached body:", here()) }.value
let t = Task { here() }
print("Task inherits:", await t.value)
}
await demo()
- start: main / nonisolated async: background / @concurrent: background / @MainActor: main / tag-ctr ctr 1 / detached body: background / Task inherits: main
- start: main / nonisolated async: main / @concurrent: background / @MainActor: main / tag-ctr ctr 1 / detached body: background / Task inherits: main
- start: main / nonisolated async: background / @concurrent: main / @MainActor: background / tag-ctr ctr 1 / detached body: main / Task inherits: background
Predict first
And with -enable-upcoming-feature NonisolatedNonsendingByDefault?
func here() -> String { Thread.isMainThread ? "main" : "background" }
nonisolated func plain() async -> String { here() }
@concurrent func hop() async -> String { here() }
@MainActor func onMain() async -> String { here() }
actor Counter {
var n = 0
let id = "ctr"
nonisolated func tag() -> String { "tag-" + id }
func bump() -> Int { n += 1; return n }
}
@MainActor func demo() async {
print("start:", here())
print("nonisolated async:", await plain())
print("@concurrent:", await hop())
print("@MainActor:", await onMain())
let c = Counter()
print(c.tag(), c.id, await c.bump())
await Task.detached { print("detached body:", here()) }.value
let t = Task { here() }
print("Task inherits:", await t.value)
}
await demo()
- Only "nonisolated async" changes: it now prints main
- Nothing changes
- Everything prints main
Mental model. A courier who either carries your parcel inside your building or hands it to an outside depot, depending on the company policy flag.
From a @MainActor function: a @MainActor function stays on main; Task { } inherits the main actor; Task.detached does not. An ordinary nonisolated async function runs on the global executor under the default language rules (the first run prints "background"), and @concurrent says that explicitly. A nonisolated synchronous member of an actor can be called without await and can read its let constants (tag-ctr ctr).
With the upcoming feature NonisolatedNonsendingByDefault (the "approachable concurrency" direction), a nonisolated async function instead runs on the caller's actor, so the same code prints "main"; @concurrent keeps the old behaviour. Check which mode your target uses before reasoning about thread hops; I ran both modes on Swift 6.4 but did not test older toolchains.
Nonisolated async leaves the actor by default.
The feature makes nonisolated async run on the caller.
Common traps
- Assuming async means background. It depends on isolation and the feature flag.
- Assuming Task.detached inherits the actor. It does not.
They might follow up with
- How do you stay off the main actor on purpose? Mark the function @concurrent.
Related questions
- 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?
← How does synthesised Decodable treat missing keys, nulls, default…