Where do a nonisolated async function, @concurrent, @MainActor and Task run when called from the main actor?

27.5.10 · L3 · Interview

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…