Actors and Data Races
Actor isolation and reentrancy, `Sendable`, `@MainActor` and global actors, and the Swift 6 / 6.2 strict-concurrency rules that turn data races into compile errors.
12 questions
JuniorTheoryVery commonWhat is an actor, and how does calling its method differ from calling a class method?
What is an actor, and how does calling its method differ from calling a class method?
An actor is a reference type that protects its mutable state from data races — only one task touches that state at a time. Calling its method from outside is asynchronous: you await it, and it may suspend until the actor is free.
Common mistakes
- ✗Thinking an actor is a value type that copies its state per caller
- ✗Believing many tasks can touch an actor's state in parallel
- ✗Expecting actor method calls to be synchronous like a class's
Follow-up questions
- →Why must a cross-actor method call be
awaited rather than called directly? - →What keeps an actor's own internal method calls synchronous?
MiddleTheoryVery commonWhat does Sendable mean, which types get it automatically, and what do you promise with @unchecked Sendable?
What does Sendable mean, which types get it automatically, and what do you promise with @unchecked Sendable?
Sendable is a compile-time promise a value is safe to pass across an isolation boundary. Value types of Sendable members, actors, and immutable final classes qualify. @unchecked Sendable mutes the check — you guarantee the safety yourself.
Common mistakes
- ✗Treating
Sendableas a runtime copy rather than a compile-time check - ✗Thinking every class conforms to
Sendableautomatically - ✗Believing
@uncheckedstill keeps the compiler's safety guarantee
Follow-up questions
- →When does a struct fail to be
Sendableautomatically? - →What must you add to a class to make
@unchecked Sendablehonest?
MiddleTheoryCommonActor reentrancy — a method awaits mid-way; which invariant can break, and how do you protect the state?
Actor reentrancy — a method awaits mid-way; which invariant can break, and how do you protect the state?
Actors are reentrant: while a method awaits, the actor may run calls that mutate state, so an invariant checked before it can be stale after. Protect it by re-reading state after each await, or keeping that section suspension-free.
Common mistakes
- ✗Believing an actor stays locked across an
await - ✗Thinking reentrancy causes a deadlock rather than interleaving
- ✗Assuming
@MainActoralone removes the reentrancy hazard
Follow-up questions
- →Why does the actor release isolation at a suspension point rather than hold it?
- →How would you redesign a method so an invariant survives an
await?
MiddleCodeCommonConvert a lock-protected shared cache class into an actor
Convert a lock-protected shared cache class into an actor
Make the type an actor and delete the NSLock — the compiler now serializes access. Every call site becomes async, so callers must await the cache. You lose synchronous access: non-async code can't read the cache inline without a Task.
Common mistakes
- ✗Assuming call sites stay synchronous after switching to an
actor - ✗Keeping the
NSLockinside the actor as if it were still needed - ✗Thinking an
actorturns the cache into a value type
Follow-up questions
- →How do you read the actor's cache from a synchronous UIKit callback?
- →Why does the compiler make every external cache read
await?
MiddleTheoryCommonWhat does @MainActor mean on a type or function, and what happens when you call it from a background context?
What does @MainActor mean on a type or function, and what happens when you call it from a background context?
@MainActor ties a type or function to the main actor, so its state is touched only on the main thread. From the background the call is async: you await it, and a direct synchronous call is a compile error.
Common mistakes
- ✗Thinking
@MainActoris a runtime hint rather than compile-time isolation - ✗Believing a background caller can invoke it synchronously without
await - ✗Assuming it inserts a blocking
main.syncunder the hood
Follow-up questions
- →Why is a
@MainActorcall from a background taskawaited rather than blocking? - →How does
@MainActoron a whole type differ from it on one method?
MiddleDebuggingCommonNon-Sendable passed across an actor boundary — walk the legitimate fixes and pick one
Non-Sendable passed across an actor boundary — walk the legitimate fixes and pick one
Legitimate fixes — make the type Sendable (a value type or immutable final class); convert it to an actor; isolate both sides to @MainActor; or pass a sending value. Prefer a Sendable value model — it removes the hazard at its source.
Common mistakes
- ✗Reaching for
@unchecked Sendableas the only fix - ✗Casting to
Anyto dodge the check - ✗Assuming a
Taskdeep-copies captured values across the boundary
Follow-up questions
- →When is converting the type to an
actorbetter than making itSendable? - →What does
sendingguarantee that plainSendabledoes not?
MiddleDesignCommonYou have one shared, mutable in-memory store read and written from many places: some on the main thread for UI, some from background networking callbacks. A teammate asks which of three tools should own its thread-safety — a dedicated actor instance, the global @MainActor, or a private serial DispatchQueue you sync/async onto. Pick one and defend it against the other two. Address correctness under concurrent access, whether callers must become async, the cost of hopping to the main thread for data unrelated to UI, reentrancy, and which choice ages best under the Swift 6 language mode. State the trade-off you accept.
You have one shared, mutable in-memory store read and written from many places: some on the main thread for UI, some from background networking callbacks. A teammate asks which of three tools should own its thread-safety — a dedicated actor instance, the global @MainActor, or a private serial DispatchQueue you sync/async onto. Pick one and defend it against the other two. Address correctness under concurrent access, whether callers must become async, the cost of hopping to the main thread for data unrelated to UI, reentrancy, and which choice ages best under the Swift 6 language mode. State the trade-off you accept.
Pick a dedicated actor: compiler-checked isolation, callers stay async without pinning data to the UI thread, and it ages best under Swift 6. @MainActor needlessly forces non-UI work onto main; a serial DispatchQueue works but is unchecked and easy to misuse. The trade-off: every access becomes async.
Common mistakes
- ✗Defaulting to
@MainActorfor state that has nothing to do with UI - ✗Trusting a serial queue's safety because it "runs one block at a time"
- ✗Ignoring that an actor makes every access
async
Follow-up questions
- →Why does
@MainActorfor a non-UI store waste main-thread time? - →What compiler guarantee does an
actorgive that a serial queue does not?
MiddleTheoryOccasionalStrict concurrency checking — minimal, targeted, complete — on a Swift 5 target; what does each level check?
Strict concurrency checking — minimal, targeted, complete — on a Swift 5 target; what does each level check?
minimal flags only code already using concurrency (explicit Sendable, actors). targeted adds APIs that opt in, staying quiet elsewhere. complete checks all code by Swift 6 language-mode data-race rules — you raise it to migrate gradually on Swift 5.
Common mistakes
- ✗Thinking the levels tune runtime locking rather than compile-time checks
- ✗Assuming
minimalchecks the whole program strictly from the start - ✗Believing
completeis unrelated to the Swift 6 language mode
Follow-up questions
- →Why adopt
completechecking before switching the language mode to 6? - →Which level would you set first on a large legacy target?
SeniorDesignOccasionalYour team is adopting Swift 6 strict concurrency on an existing UIKit app and drowning in Sendable and isolation errors, most of them on ordinary UI code that only ever runs on the main thread. A colleague points to Swift 6.2 / Xcode 26 "Approachable Concurrency" and its Default Actor Isolation build setting, and proposes setting it to MainActor. Explain what that build setting changes about where unannotated declarations run, why it removes most of the errors you see, what you must still mark nonisolated and why, and how you would adopt it incrementally across a multi-module app without silently pushing background work onto the main actor. Note that it is opt-in and off by default.
Your team is adopting Swift 6 strict concurrency on an existing UIKit app and drowning in Sendable and isolation errors, most of them on ordinary UI code that only ever runs on the main thread. A colleague points to Swift 6.2 / Xcode 26 "Approachable Concurrency" and its Default Actor Isolation build setting, and proposes setting it to MainActor. Explain what that build setting changes about where unannotated declarations run, why it removes most of the errors you see, what you must still mark nonisolated and why, and how you would adopt it incrementally across a multi-module app without silently pushing background work onto the main actor. Note that it is opt-in and off by default.
Default Actor Isolation set to MainActor defaults unannotated declarations to @MainActor, so UI code is main-isolated automatically, clearing the main-thread errors. Mark real background work nonisolated or move it to an actor so it stays off main. It is opt-in, off by default, adopted module by module.
Common mistakes
- ✗Thinking the setting is on by default or a runtime thread change
- ✗Believing it disables strict-concurrency checking
- ✗Forgetting to mark background work
nonisolated, stalling the UI
Follow-up questions
- →How do you keep a CPU-bound service off the main actor under this setting?
- →Why adopt Default Actor Isolation one module at a time?
SeniorTheoryOccasionalnonisolated, isolated parameters, and nonisolated(unsafe) — what does each do, and when is the last defensible?
nonisolated, isolated parameters, and nonisolated(unsafe) — what does each do, and when is the last defensible?
nonisolated drops a member out of isolation — synchronous, touching only Sendable/immutable state. An isolated parameter runs a function in an actor's isolation. nonisolated(unsafe) mutes the checker on a declaration, defensible only for externally protected storage.
Common mistakes
- ✗Swapping what
nonisolatedand anisolatedparameter do - ✗Thinking these annotations are optimizer hints, not isolation controls
- ✗Believing
nonisolated(unsafe)is safe because the runtime still locks
Follow-up questions
- →What state may a
nonisolatedmethod legally read? - →What must be true about a value before
nonisolated(unsafe)is honest?
SeniorTheoryOccasionalWhat becomes a hard compile error in the Swift 6 language mode that was only a warning in Swift 5 mode?
What becomes a hard compile error in the Swift 6 language mode that was only a warning in Swift 5 mode?
Under the Swift 6 language mode, complete concurrency checking is mandatory, so data-race violations become errors, not warnings — sending a non-Sendable across an isolation boundary, or reaching actor-isolated state without await. The toolchain can still build Swift 5 mode.
Common mistakes
- ✗Conflating the Swift 6 toolchain with the Swift 6 language mode
- ✗Thinking the new errors are about optionals rather than data races
- ✗Believing the checks fire at runtime instead of at compile time
Follow-up questions
- →How do you opt a single module into the Swift 6 language mode?
- →Why keep Swift 5 mode available on the Swift 6 toolchain?
SeniorTheoryRareWrite your own @globalActor — when does a global actor beat an actor instance?
Write your own @globalActor — when does a global actor beat an actor instance?
A @globalActor exposes a shared actor; annotate many types with it and they share one isolation domain. It beats a standalone actor instance when unrelated code app-wide must be serialized on one shared executor, like @MainActor for the UI.
Common mistakes
- ✗Thinking
@globalActoris just shorthand for anactorinstance - ✗Believing it creates a separate isolation domain per annotated type
- ✗Treating a global actor as a dedicated OS thread
Follow-up questions
- →How does the
sharedrequirement make a global actor a single domain? - →Why is
@MainActorthe archetypal global actor?