How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?

10.4.2 · L5 · Interview

Reported at: Apple

Predict first

Can a nonisolated function mutate an actor’s property directly?

actor A { var n = 0 }
func f(_ a: A) { a.n += 1 }
  • Yes, it is just a property
  • No: it is actor-isolated and needs await

Predict first

What does a function with an isolated parameter read without await?

actor Bank { var bal = 7 }
func onActor(_ a: isolated Bank) -> Int { a.bal }
print(await onActor(Bank()))
  • 7
  • It does not compile
  • 0

Mental model. Isolation domains are rooms with one door each. Inside a room you move freely; to use something in another room you knock and wait (await), and heavy work belongs in a workshop room, not the lobby.

Every piece of code has an isolation domain: an actor instance, a global actor, or none (nonisolated). A global actor is a single, shared actor you attach with an attribute; @MainActor is the built-in one for the main thread and UI, and you can declare your own with @globalActor. Put @MainActor on a type and all its members are isolated to the main actor (and, since it infers isolation, so are its subclasses, and types conforming to protocols declared @MainActor); put it on a function or property to isolate only that. Code outside the domain must await to reach isolated members, and synchronous access from a different domain is a compile error, such as mutating an actor’s property from a nonisolated function.

Isolation propagates in predictable ways. A closure or Task { } created in an isolated context inherits the isolation (a Task started from a @MainActor method runs on the main actor); Task.detached and @Sendable closures do not. An async function declared nonisolated runs on the global concurrent executor (not the caller’s actor) by default, which is the usual way to move heavy work off the main actor, and recent language versions let you choose to inherit the caller’s isolation instead (the nonisolated(nonsending) behaviour). nonisolated on a member of an actor makes it callable synchronously from outside, allowed only if it touches no isolated state (for example computing from let properties or conforming to Hashable). An isolated parameter (func f(_ a: isolated Bank)) makes the function run on that actor, so it can read the state synchronously. Hops between isolation domains cost a suspension and an await, so batch work: one hop to do several things, rather than many small calls. Use MainActor.assumeIsolated only when you can prove you are on the main thread, because it traps otherwise.

The property is isolated to the actor, so access from outside must go through the actor, with await.

The function runs on the actor, so it can read the isolated property directly. Only the call to it needs await.

Common traps

  • Doing heavy synchronous work in a @MainActor method. It blocks the UI; move it to a nonisolated async function or another actor.
  • Using Task.detached to escape the main actor and losing priority and task locals. A nonisolated async function does it more cleanly.

They might follow up with

  • Where does a Task { } from a @MainActor method run? On the main actor, because it inherits isolation.
  • What does an isolated parameter do? It runs the function on that actor, so it can access isolated state without await.

Related questions

  • 10.4.1 What does an actor guarantee, and what is actor reentrancy?
  • 10.3.2 How do tasks form a tree: Task, task groups, async let and detached tasks, and what does a child inherit?
  • 10.5.2 How do you migrate an existing app to Swift 6 and strict concurrency without breaking it?

← What does an actor guarantee, and what is actor reentrancy?