How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?
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
@MainActormethod. It blocks the UI; move it to anonisolatedasync function or another actor. - Using
Task.detachedto escape the main actor and losing priority and task locals. Anonisolated asyncfunction does it more cleanly.
They might follow up with
- Where does a
Task { }from a@MainActormethod run? On the main actor, because it inherits isolation. - What does an
isolatedparameter do? It runs the function on that actor, so it can access isolated state withoutawait.
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?