What does the Swift 6.2 default actor isolation setting change, and how does it affect a global variable or function you call from a background task?

10.9.5 · L5 · Interview

Predict first

With the main-actor default, can a detached task call touch() directly?

var shared = 0
func touch() { shared += 1 }
func off() { Task.detached { touch() } }
off()
  • No, compile error
  • Yes, it runs on the main thread
  • Yes, but with a warning only

Predict first

And with an explicit hop to the main actor?

var shared = 0
func touch() { shared += 1 }
func fromBackground() async {
    await Task.detached { await MainActor.run { touch() } }.value
}
await fromBackground()
print("ok", shared)
  • It compiles and prints "ok 1"
  • Compile error
  • It prints "ok 0"

Mental model. A school where every room is “staff only” by default. You get fewer accidents, but anything meant for the playground has to be labelled.

By default (and in Swift 6 language mode) code in a module is nonisolated unless you annotate it, so shared mutable state needs explicit protection. Swift 6.2 introduced the compiler option -default-isolation MainActor (the Xcode build setting is “Default Actor Isolation”, and new app targets can start with it set to MainActor) so that, unless you say otherwise, declarations in the module are @MainActor. The motivation is that most app code is UI-driven and single-threaded in practice, and the safest default is “everything is on the main actor”; you opt out (nonisolated, an actor, @concurrent) for the parts that must run elsewhere.

The check compiles with -default-isolation MainActor. Now shared and touch() are main-actor isolated, so a nonisolated Task.detached closure cannot call touch() directly (compile error), while the explicit hop await MainActor.run { touch() } is legal and prints “ok 1”. (In a single-file script the top-level variables are already main-actor isolated, so this shows the rule rather than the before-and-after; in a multi-file module a plain global var is nonisolated and Swift 6 rejects it as shared mutable state unless you isolate or protect it.) The trade-offs: far fewer annotations and false data-race errors in app code; but background work must now be explicitly marked, a library target meant to be used from any thread should keep the default nonisolated, and the setting is per target, so mixed projects need a deliberate choice for each module (Q298).

The function is main-actor isolated, so a nonisolated detached closure cannot call it synchronously.

MainActor.run is the explicit hop that makes the call legal.

Common traps

  • Turning on main-actor default isolation in a shared library. Everything in it becomes main-actor bound for its callers.
  • Forgetting heavy work is now on the main actor too. Mark parsing, image work and I/O nonisolated or @concurrent.

They might follow up with

  • Does this remove the need to understand actors? No: it moves the default, and the opt-outs are exactly the actor rules you already know.
  • Where is it configured? Per target: the Xcode build setting, or the defaultIsolation option in a package manifest’s Swift settings.

Related questions

  • 10.9.4 Where does code actually run after an await: what do @MainActor, MainActor.run and a nonisolated async…
  • 10.9.3 How do you define your own global actor, what does annotating a type with it do, and when is it better than…
  • 10.10.3 What are @unchecked Sendable and nonisolated(unsafe) promising, and what does the compiler still check when…
  • 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?

← Where does code actually run after an await: what do @MainActor…