How do you define your own global actor, what does annotating a type with it do, and when is it better than an actor instance or the main actor?

10.9.3 · L5 · Interview

Predict first

Ten tasks each add 100 items to a @Store class. What is the count?

@globalActor actor Store { static let shared = Store() }
@Store final class Cache {
    var items: [Int] = []
    func add(_ n: Int) { items.append(n) }
}
@Store func fill(_ c: Cache) { for i in 0..<100 { c.add(i) } }

let c = await Cache()
await withTaskGroup(of: Void.self) { g in
    for _ in 0..<10 { g.addTask { await fill(c) } }
}
print(await c.items.count)
  • 1000
  • Fewer than 1000, a data race
  • Compile error

Mental model. A global actor is a single shared reception desk for a whole department. Anyone who wants department records queues at the same desk, regardless of which team they are on.

A global actor is an actor with a static let shared instance, marked @globalActor. Annotating a function, property, or whole type with @Store makes all its members isolated to that one shared executor, across files and types. The check marks Cache and fill with @Store: ten concurrent callers each append 100 items and the result is exactly 1000, because every access is serialised on the same actor even though Cache is a plain class with no locks.

The value is sharing one isolation domain across many types without passing an actor instance around: a database layer (@DatabaseActor), an audio engine, a legacy subsystem you are fencing in. @MainActor is simply the built-in global actor tied to the main thread. Prefer an ordinary actor instance when the state belongs to an object with its own lifetime (an account, a download); prefer a global actor when the state is conceptually one subsystem. Cost and caution: every cross-boundary call is an await; a global actor can become a bottleneck if unrelated work all queues on it; and unlike the main actor it gives you no thread guarantee, only serial access.

Everything isolated to @Store runs serially on its shared executor, so no update is lost.

Common traps

  • Putting unrelated work on one global actor. Everything queues behind everything else.
  • Assuming a custom global actor runs on a particular thread. Only @MainActor promises the main thread.

They might follow up with

  • Can a type be partly isolated? Yes: annotate the type, then mark specific members nonisolated, or annotate only some members.
  • How does isolation propagate to subclasses and closures? Subclasses inherit it, and closures formed in isolated code inherit it unless @Sendable (Q296).

Related questions

  • 10.9.2 What is an isolated parameter, and when would you write a function that takes one instead of making it an…
  • 10.9.4 Where does code actually run after an await: what do @MainActor, MainActor.run and a nonisolated async…
  • 10.10.2 How do you choose between an actor, a Mutex, an unfair lock, an atomic and a serial queue to protect shared…
  • 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?

← What is an isolated parameter, and when would you write a function…   Where does code actually run after an await: what do @MainActor… →