cswork / libraryOpen practice workspace

Articles

Swift Actor Interview Questions: Isolation, Reentrancy, and Sendable

Practice Swift Actor interviews with reentrancy traces, reservation invariants, Sendable boundaries, MainActor assumptions, and controlled concurrency tests.

An actor checks that an account has enough balance, awaits authorization, and subtracts the amount. Two callers each pass the check before either subtraction runs. The code can avoid a low-level data race and still violate the rule that available balance must never become negative.

That is the central distinction in Swift Actor interview questions: actor isolation protects access to state, while your design must preserve business invariants across suspension points. Adding the actor keyword does not make an entire async method a transaction.

This guide develops a withdrawal trace, a reservation-based repair, and a Sendable decision table. Official language facts come from the Swift book and Swift Evolution proposals. Candidate reports provide limited preparation context: a Whoop iOS experience mentions Swift concurrency discussion but does not establish this article's exact questions. Our inference and recommendations supply the worked examples and preparation method.

Begin with Make Cache Fetch Thread-Safe and Single-Flight, a useful adjacent exercise for reasoning about state before and after slow work.

What does actor isolation actually guarantee?

Actors protect their isolated mutable state by controlling access to it. Code outside the isolation domain cannot casually read and mutate that state as if it were an ordinary shared object. The Swift concurrency chapter explains actors and isolation; SE-0306 gives the detailed design and reentrancy rationale.

The useful unit of reasoning is a synchronous stretch of actor-isolated execution. While that stretch runs, another task does not simultaneously execute conflicting isolated work on the same actor. At a suspension point, other work may make progress and change state before the original operation resumes.

Avoid describing an actor as a permanently assigned thread. Isolation is an execution and access contract. Thread identity, scheduling order, and business-level atomicity are different concerns. A correct answer does not need to promise a dedicated operating-system thread for every actor.

Why can a check become stale across await?

Consider this intentionally flawed shape:

actor Wallet {
    var available = 100

    func withdraw(_ amount: Int,
                  authorize: @Sendable () async -> Bool) async -> Bool {
        guard amount > 0, available >= amount else { return false }
        guard await authorize() else { return false }
        available -= amount
        return true
    }
}

The problem is the separation between checking and consuming available funds. Suppose each caller wants 80:

StepCaller ACaller BAvailable
1Checks 100 is enoughNot started100
2Suspends in authorizationChecks 100 is enough100
3Still suspendedSuspends in authorization100
4Resumes and subtracts 80Waiting20
5FinishedResumes and subtracts 80-60

This is a permitted illustrative interleaving, not a claim that it occurs on every run. The actor serialized each individual mutation. It did not preserve an assumption made before an await.

The general review question is: which facts read before suspension must still be true afterward? Apply it to balances, selected user IDs, authentication state, cache generations, and reservation ownership.

How can a reservation preserve the invariant?

Reserve the local capacity before suspending. Then either finalize the operation or release that reservation after an unsuccessful result. For a simplified example with no other balance mutation paths:

actor ReservedWallet {
    private var available = 100

    func withdraw(_ amount: Int,
                  authorize: @Sendable () async -> Bool) async -> Bool {
        guard amount > 0, available >= amount else { return false }
        available -= amount
        let accepted = await authorize()
        if !accepted { available += amount }
        return accepted
    }
}

Now a second caller sees the reserved amount as unavailable. The check and reservation occur without suspension between them. The example illustrates a local invariant; it is not production payment code and does not implement durable recovery, throwing authorization, cancellation, or reconciliation.

A real workflow should identify each reservation and define pending, accepted, rejected, and uncertain outcomes. A network timeout may mean authorization succeeded but the response was lost. Automatically refunding local capacity on every error can allow overspending if the external effect already happened.

Another repair is rechecking after await before committing, when the external operation is only a reversible validation. Choose the strategy according to the effect's semantics. If authorization has already charged money, a second local check cannot undo that charge by returning false.

Does an actor cache automatically prevent duplicate fetches?

No. A method can check the cache, find a miss, await a load, and store the result. Another caller can observe the same miss while the first is suspended and start another load.

For single-flight behavior, represent in-progress work in actor state before suspension. Later callers can join that operation rather than starting another. Define whether they share errors and whether one caller's cancellation should cancel work needed by others.

Deletion introduces a further race. If a key is invalidated while a load is running, an old completion must not repopulate it as if no invalidation occurred. A generation or operation identity can make publication conditional on still owning the current slot.

Cleanup needs the same check. An older task should not remove a newer in-flight entry merely because both use the same key. This is why a dictionary of tasks is a starting point, not a complete proof of a correct cache.

What does Sendable say about a value?

Sendable expresses that a value can safely cross concurrency boundaries under Swift's model. SE-0302 introduces the protocol and concurrent closure checking. It is not a synonym for immutable syntax or a promise that every operation on the value is atomic.

A struct can contain a reference to mutable shared state. Calling it a value type does not automatically make that referenced state safe to share. Conversely, a carefully synchronized reference type can have a valid concurrency contract, although proving it requires more than adding a conformance declaration.

@Sendable constrains a closure's captures and use across concurrency boundaries. It does not mean “execute on a background thread.” Explain both what the closure captures and which isolation context may execute it.

Boundary valueQuestion to askDefensible direction
Small immutable snapshotAre all stored values safely transferable?Prefer explicit data snapshots
Struct containing a mutable classWho else can mutate the referenced instance?Redesign ownership or isolate the reference
Actor referenceAre accesses respecting actor isolation?Use the actor's asynchronous interface
Callback capturing UI stateWhat isolation does the callback require?Make the intended actor boundary explicit
Unchecked conformanceWhat synchronization proof replaces compiler checking?Document and test the actual invariant

@unchecked Sendable moves responsibility to the programmer. It does not make an unsafe class safe. In an interview, use it only when you can identify the synchronization mechanism and explain every mutable access path.

How do MainActor and nonisolated change the answer?

MainActor provides an isolation domain commonly used for UI state. It helps express where UI-related mutation belongs. It does not make a long synchronous computation cheap: work performed while occupying the main actor can still delay the interface.

An asynchronous method is not automatically background work. Execution behavior depends on isolation annotations, language mode, and relevant language features. State those assumptions before making a claim about where code runs.

A nonisolated member does not gain permission to access mutable actor-isolated state unsafely. It must obey the compiler's rules for what can be used outside that domain. Prefer returning an appropriate snapshot through an isolated method when the data is mutable and owned by the actor.

Do not use detached tasks as a blanket escape from compiler diagnostics. Detaching changes context and task relationships; captured values still need a valid concurrency story. A diagnostic often points to an unclear boundary that should be designed, not suppressed.

Which version assumptions should you state?

Swift concurrency diagnostics and inference have evolved. Record compiler version, language mode, strict-concurrency settings, and default actor-isolation configuration when presenting reproducible examples. A snippet compiling in one project does not prove it has the same diagnostics in another.

The examples here are intentionally small and use explicit actors and Sendable closure parameters. When adapting them to an application, account for framework annotations and any enabled upcoming features. Avoid repeating old rules about async execution without checking the current toolchain.

Separate a migration warning from a runtime observation. A compiler error about transferring a non-Sendable value is evidence about the language's safety rules; it is not a measured race in a running app. Similarly, a clean build does not prove the business invariant survives reentrancy.

How should actor cancellation and errors be handled?

Cancellation is cooperative. If an operation reserves state before awaiting work, define what happens on cancellation, rejection, and an uncertain external result. These states may require different transitions.

Keep cleanup ownership explicit. A request that no longer owns a reservation or cache generation must not clear another request's state. If cleanup itself suspends, inspect that additional reentrancy boundary too.

For a purely local operation, restoring reserved capacity may be straightforward. For a remote side effect, stable operation IDs and reconciliation may be needed. Actor isolation cannot create a distributed transaction with an external server.

Also distinguish cancellation of one waiter from cancellation of shared work. In a single-flight cache, several callers may depend on the same load. Decide whether a cancelled caller merely stops waiting or whether the shared operation has no remaining owners and can be cancelled safely.

How do you test reentrancy without relying on luck?

Use a controllable dependency to pause at the interesting await. Start caller A, wait until it reaches that dependency, then start B and release the dependency in a chosen order. Assert the invariant after each relevant transition.

A stress test can supplement this, but sleeping and hoping two tasks overlap is weaker than controlling the schedule. The test should demonstrate the exact stale assumption, not merely report that a thousand attempts happened to pass.

For the wallet example, assert available funds never go below zero in the repaired design and that a rejected authorization releases only its own reservation. For the cache, test one shared load, invalidation during load, a newer generation replacing an old one, and error cleanup.

Check externally observable results as well as internal state. Two callers can both receive success even when a later assertion repairs a balance field. Correctness includes the promises the API made to callers.

Sources and Further Reading