Skip to content
All writing

Swift concurrency

An actor protects state. It does not own every retain and release.

Actor isolation is a concurrency guarantee. ARC is a lifetime mechanism. Treating one as a complete proof about the other is where the mental model starts to drift.

6 min read

Audio edition is being recorded

Three separate layers: isolation (actor) answers who may access state, transfer (Sendable) whether a value can cross a boundary, lifetime accounting (ARC) how the object is kept alive.

Swift actors make an important promise.

They isolate mutable state.

That promise is strong enough for Swift 6 to reject programs that cross concurrency boundaries unsafely, and it gives us a much better language for reasoning about data races.

But there is a tempting shortcut hiding inside that model:

If an object is actor-isolated, its references must be isolated too.

From there it is easy to jump again:

If those references are isolated, ARC should not need atomic reference counting.

That sounds reasonable.

It is also more than actor isolation actually promises.

Start with the guarantee actors do make

An actor protects its actor-isolated state from concurrent access that violates Swift's isolation rules.

An actor also conforms to Sendable.

Those are language-level concurrency properties.

They help answer questions like:

  • Can this mutable state be accessed from here?
  • Does this value cross an isolation boundary legally?
  • Does this code need await?
  • Is the compiler able to prove that a particular access is safe?

Those are valuable guarantees.

But notice what is missing.

Actor isolation does not say:

Every reference-count operation associated with every object reachable from this actor happens only while executing on this actor.

That would be a much broader lifetime guarantee.

A reference can participate in more than one mechanism

Consider an actor that stores a class instance:

Swift
final class Payload {
    let value: Int

    init(value: Int) {
        self.value = value
    }
}

actor Store {
    private var payload = Payload(value: 42)

    func currentValue() -> Int {
        payload.value
    }
}

The actor isolates access to payload as actor state.

That tells you something important about how the property can be accessed.

But ARC is not merely a record of source-level property accesses.

Reference lifetime can be affected by generated temporaries, function arguments, return values, captures, ownership conventions, optimizer decisions and other lowering details.

So there are two different questions:

Text
Who may access this state?

and:

Text
How is this object's lifetime represented and updated in generated code?

The first is where actor isolation operates.

The second belongs to ownership, ARC and compiler lowering.

This is exactly what the ARC experiment tested

I built a small Swift 6 lab around this distinction.

The negative control was a deliberately illegal case: non-Sendable mutable reference state crossing a concurrency boundary.

Swift 6 rejected it.

Then I tested valid cases using actor isolation, Sendable, and @unchecked Sendable.

Those cases compiled.

If concurrency isolation also acted as a direct ARC atomicity selector, this would be a reasonable place to expect the generated reference-counting path to change.

In the Apple Swift 6.3.3 cases I tested, it did not.

Ordinary builds still emitted:

Text
swift_retain
swift_release

The actor-isolated and Sendable cases did not switch those observed calls to:

Text
swift_nonatomic_retain
swift_nonatomic_release

Then I changed a different assumption.

I compiled with:

Text
-Xfrontend -assume-single-threaded

Now the observed calls changed to the non-atomic variants.

That is a much cleaner signal than trying to infer ARC behaviour from the presence of actor or Sendable.

The A/B

For the controlled retain/release case in the lab:

BuildRetainRelease
normal -Ononeswift_retainswift_release
single-threaded -Ononeswift_nonatomic_retainswift_nonatomic_release
normal -Oswift_retainswift_release
single-threaded -Oswift_nonatomic_retainswift_nonatomic_release

The result is deliberately narrow.

It does not prove that Swift ARC is always atomic.

It does not prove that actor isolation can never participate in an ARC optimization.

It does not prove that Sendable can never influence optimization.

It shows that in these tested Apple Swift 6.3.3 cases, changing the concurrency validity of the program did not change the observed ARC runtime entry-point selection, while the explicit single-threaded compiler assumption did.

That is compiler/runtime implementation evidence for the tested toolchain, not a Swift language guarantee.

Why `Sendable` does not close the gap

Sendable is another place where the same mental shortcut appears.

A type conforming to Sendable is saying something about crossing concurrency boundaries safely under Swift's concurrency model.

For unchecked conformance:

Swift
final class Box: @unchecked Sendable {
    // You are taking responsibility for the safety contract.
}

the compiler is not proving your synchronization strategy for you.

You are asserting that the type can be used safely across those boundaries.

That still does not mean:

Text
Sendable

non-atomic refcount

Sendable is not an ARC annotation.

The Swift documentation describes @unchecked Sendable in exactly that spirit: you assume responsibility for correctness when the compiler cannot enforce the conformance itself.

Isolation is about access, not exclusive lifetime ownership

The easiest way to remember the distinction is:

Actor isolation controls access to isolated state. It does not mean the actor is the only place where all lifetime bookkeeping for every related reference can occur.

That difference becomes more visible when values move across APIs.

An actor method can return a Sendable value.

A reference may be captured by generated code.

A function call may introduce ownership traffic that is not interesting at the source level.

Optimization may remove, combine or reposition retain/release operations.

None of that means actor isolation is broken.

It means source-level isolation and runtime lifetime accounting are different layers of the implementation.

Why this matters for performance reasoning

This is where developers can accidentally turn a correctness feature into an imagined performance guarantee.

For example:

I moved this behind an actor, so ARC can be cheaper now.

That conclusion does not follow from the actor model.

The actor may absolutely improve the correctness and architecture of the code.

It may also change performance in many ways, including scheduling and serialization costs.

But if your performance claim is specifically about reference-count atomicity, you need evidence from generated code or runtime behaviour.

Do not infer it from actor.

Do not infer it from Sendable.

Measure the layer you are making the claim about.

Why SIL alone is not enough

This also explains why looking at one intermediate representation can be misleading.

SIL exposes ownership operations, but the question in the ARC experiment was narrower:

Which runtime ARC entry points survive into the generated program?

For that, LLVM IR and assembly are more directly useful.

Optimization can eliminate or combine ARC traffic, so even the presence of an ownership operation in one stage does not guarantee a corresponding runtime call later.

This is why the lab keeps SIL, LLVM IR and assembly evidence separate.

The more specific your claim, the closer your evidence should be to the layer you are claiming something about.

A better senior-level mental model

When reasoning about Swift concurrency and ownership, keep three layers separate:

1. Isolation

Who is allowed to access this state, and from which concurrency domain?

Actors and global actors live here.

2. Transfer

Can this value cross a concurrency boundary safely?

Sendable lives here.

3. Lifetime accounting

How does the generated program keep the referenced object alive and eventually destroy it?

ARC and compiler lowering live here.

These layers interact.

They are not interchangeable.

That is why a program can become valid under Swift 6's concurrency checker while the ARC entry points observed in generated code remain unchanged.

The practical rule

If Swift 6 rejects the program, fix the concurrency problem.

If you want to know what ARC is doing, inspect ARC.

Do not use one as a proxy for the other.

That sounds obvious when written down.

It is much less obvious when an actor has just given the compiler a strong new proof and you are staring at a pair of swift_retain / swift_release calls that refuse to disappear.

The compiler is not contradicting itself.

You were asking two different questions.

Reproduce the ARC experiment

The full lab is public:

https://github.com/salari-dev/swift6-arc-atomicity-lab

It includes:

  • the negative concurrency control;
  • actor-isolated, Sendable, and @unchecked Sendable cases;
  • SIL, LLVM IR and assembly evidence;
  • the atomic versus non-atomic A/B;
  • compiler-selection tracing;
  • reproduction scripts.

The tested environment documented in the repo is Apple Swift 6.3.3, Xcode 26.6, macOS 26.6.2, arm64, Swift language mode 6, with iPhone 17 Simulator used for simulator validation.

No second official Swift toolchain was tested, so this should not be presented as a permanent language rule.

References

Go deeper

  1. Book

    SwiftUI Under Load

    Production SwiftUI when the clean examples stop being clean. State, concurrency, rendering, performance and failure modes for experienced iOS engineers.

  2. Book

    Share Your Screen

    Live coding for senior iOS interviews. Not just getting the code right, but explaining decisions, handling follow-ups and recovering when the happy path breaks.

  3. Practice tool and book

    Interview Runtime

    You already know more Swift than you can reliably retrieve under interview pressure. Practice the answer, then deal with the follow-up.

Preparing for an iOS interview tomorrow rather than studying internals? 24-Hour iOS Interview Answer Book →

Share this essay