Swift concurrency
Swift 6 rejected the race. ARC stayed atomic.
Swift 6 can prove that some code is safe from data races. That does not mean reference counting suddenly becomes non-atomic.
8 min read · feels like 2
Audio edition is being recorded
Swift 6 rejected the unsafe program exactly where I expected it to.
I had a small reference-type experiment with state shared across concurrency boundaries. In Swift 6 language mode, the negative control failed with concurrency diagnostics. Good. That part was boring in the best possible way.
Then I fixed the concurrency problem.
I moved the state behind actor isolation. I tested Sendable variants. I also tested an @unchecked Sendable case where I was explicitly taking responsibility for the safety guarantee.
The code compiled.
Then I looked at what ARC was doing.
I expected the generated code to change.
It did not.
In the Apple Swift 6.3.3 cases I tested, the runtime still went through the normal:
swift_retain
swift_releaseActor isolation had changed what Swift could prove about access to the program state.
It had not changed the ARC runtime entry points I was observing.
That was the interesting part.
The question
Swift 6 has a much stronger concurrency model than earlier Swift versions.
The compiler can reject code that moves non-Sendable state across isolation boundaries. Actors provide isolation. Sendable describes values that can safely cross concurrency domains. Strict concurrency checking catches classes of mistakes that previously survived compilation.
So I wanted to test a fairly simple question:
If Swift 6 can prove that some object access is isolated from concurrent mutation, can ARC use cheaper non-atomic reference counting for that object?
That sounds plausible.
If the compiler knows an object is actor-isolated, why pay synchronization costs for reference-count updates that should not race?
The problem is that two different questions are being mixed together.
Swift concurrency asks whether program state is being accessed or transferred safely.
ARC asks how object lifetime is accounted for.
Those are related, but they are not the same contract.
I wanted to see where that distinction showed up in generated code rather than stopping at the language model.
The experiment
I ran the experiment using Apple Swift 6.3.3 from Xcode 26.6, explicitly compiling in Swift 6 language mode.
The test environment was:
Apple Swift 6.3.3
Xcode 26.6
macOS 26.6.2
arm64
Swift language mode 6
iPhone 17 Simulator for simulator validationThe repository contains a negative control plus several valid cases, including actor isolation and Sendable variants.
The negative control matters.
Without it, seeing a successful Swift 6 build tells us almost nothing. I wanted a case where the compiler demonstrably rejected unsafe sharing, then closely related cases where the concurrency problem had actually been addressed.
The unsafe case failed as expected.
The valid cases compiled.
Then I looked at multiple stages of compiler output.
That distinction matters because SIL ownership instructions are not the same thing as the final ARC runtime calls.
At the SIL level you can see ownership operations and retain/release behaviour represented in Swift's intermediate representation.
But if the question is:
Which runtime reference-counting entry point does this build actually use?
then LLVM IR and assembly are the more interesting places to look.
In the ordinary Swift 6 builds I tested, I continued to find:
swift_retain
swift_releaseThat remained true when the concurrency model around the object changed.
Actor isolation did not turn the observed calls into non-atomic ARC operations.
Neither did the Sendable or @unchecked Sendable cases I tested.
Then I changed one compiler assumption
There is a compiler option that makes this experiment much more useful:
-Xfrontend -assume-single-threadedThe name is refreshingly direct.
Instead of asking the compiler to infer from actor isolation that some individual object's reference counting can be cheaper, this flag changes a much broader assumption about the program.
The compiler is being told to assume single-threaded execution.
I compiled the controlled case again.
This time, the output changed.
Instead of the ordinary ARC runtime entry points, I observed:
swift_nonatomic_retain
swift_nonatomic_releaseAnd that happened in both the unoptimized and optimized configurations I tested.
The clean A/B looked like this:
| Build | Retain | Release |
|---|---|---|
normal -Onone | swift_retain | swift_release |
single-threaded -Onone | swift_nonatomic_retain | swift_nonatomic_release |
normal -O | swift_retain | swift_release |
single-threaded -O | swift_nonatomic_retain | swift_nonatomic_release |
That gave me the selector I wanted.
The same general reference-counting operation could be lowered differently when the compiler was explicitly told to assume a single-threaded program.
But Swift 6 actor isolation by itself had not produced the same result in my experiment.
Why this distinction matters
It would be easy to look at Swift 6 concurrency and reason like this:
- An actor guarantees isolated access.
- Therefore this reference cannot be touched concurrently.
- Therefore ARC for this reference does not need atomic operations.
The first statement can be valid.
The jump from the first statement to the third is where things become much less obvious.
Actor isolation is a source-level concurrency guarantee about access to isolated state.
ARC lifetime operations may happen for references that move through code outside the isolated body, through calling conventions, temporary values, captures, ownership transfers, bridging, optimizer transformations, and other parts of the generated program.
A proof that some mutable state is actor-isolated is not automatically the same thing as a whole-program proof that every relevant lifetime operation for an object can use non-atomic accounting.
The experiment does not prove why every individual retain or release remains on a particular runtime path.
What it does show is narrower and more useful:
In the Apple Swift 6.3.3 cases I tested, making the program concurrency-safe through actor isolation,Sendable, or@unchecked Sendabledid not change the observed ARC calls fromswift_retain/swift_releaseto their non-atomic counterparts.
Explicitly switching the compiler to a single-threaded assumption did.
This is compiler/runtime implementation evidence for the tested toolchain, not a Swift language guarantee.
That distinction matters.
Following the compiler
I also traced the relevant compiler path instead of treating the runtime symbol names as magic.
For the toolchain path examined in the repository, the single-threaded assumption flows roughly like this:
frontend option
↓
CompilerInvocation
↓
SILOptions::AssumeSingleThreaded
↓
SILModule::isDefaultAtomic()
↓
IRGen Atomicity
↓
GenHeap runtime call selectionThat is useful because it connects the frontend assumption to the ARC lowering decision.
The interesting part is what I did not find in that path.
I did not find a corresponding rule that says:
actor-isolated object
↓
non-atomic ARCor:
Sendable
↓
non-atomic ARCAnd the generated output from the experiment matched that.
That does not mean such an optimization could never exist.
It means I could not demonstrate it in these cases, with this toolchain, and the compiler path I traced points to a broader default-atomicity decision rather than a simple mapping from Swift concurrency annotations to ARC atomicity.
There is one additional limitation worth stating clearly: the open-source Swift source revision inspected in the repo is not proven to be the exact source revision Apple used to build Swift 6.3.3.
So the compiler-source tracing is supporting evidence, not a claim of exact source-build identity.
Sendable is not an ARC annotation
This is also a useful reminder about Sendable.
Sendable is frequently discussed as if it were a general "thread safe" marker.
That is too vague.
Its job is about whether values can safely cross concurrency boundaries under Swift's concurrency model.
It does not say:
Use non-atomic reference counting for this instance.
Likewise, @unchecked Sendable does not unlock an ARC optimization.
It tells the compiler that you are taking responsibility for a guarantee it cannot verify itself.
In my tested cases, neither changed the ARC runtime calls I was looking at.
That separation is important because otherwise it becomes very easy to assign performance properties to concurrency annotations that the language never promised.
Actor isolation is still doing real work
None of this makes actor isolation less valuable.
The negative control was rejected.
The isolated version compiled.
That is a meaningful change in the program.
Swift 6 was enforcing a concurrency contract and preventing a class of unsafe access.
What did not happen was a second, independent assumption:
Because this code is now accepted by the concurrency checker, ARC can use non-atomic lifetime accounting.
Those are two different decisions.
That distinction is probably the main thing I took away from the experiment.
Swift's concurrency model can become more precise without every other subsystem automatically inheriting those proofs.
Why not just look at SIL?
Because SIL can easily tempt you into making claims one level too early.
Ownership operations in SIL tell you something about Swift's ownership representation and optimization pipeline.
They do not necessarily tell you which reference-counting runtime function survives into the final generated program.
Optimization makes this even more complicated.
A retain may disappear. Two operations may be combined. A lifetime can be shortened or extended. Something visible at -Onone may not exist at -O.
For this question, I wanted to check SIL, then continue down to LLVM IR and assembly rather than infer the final runtime behaviour from one intermediate stage.
That also makes the experiment easier to falsify.
You do not have to trust my interpretation of a SIL ownership instruction.
You can inspect the generated IR or assembly and search for the actual runtime symbols.
What this experiment does not prove
There are several conclusions I would not draw from this.
It does not prove:
Swift ARC is always atomic.
It does not prove:
Actor-isolated objects always use atomic refcounts.
It does not prove:
Sendable can never influence ARC optimization.It does not prove:
Atomic reference counting protects your mutable program state.
It does not prove:
-assume-single-threaded is a production optimization you should enable.And it does not prove that the behaviour observed in this toolchain is a permanent Swift language rule.
Those claims are much broader than the evidence.
The result is specifically about the compiler configurations and source cases I tested.
The defensible claim is:
In these Apple Swift 6.3.3 experiments, actor isolation,Sendable, and@unchecked Sendablechanged the concurrency validity of the program but did not change the observed ARC runtime entry points. Explicitly compiling with-assume-single-threadeddid.
That is the claim I am comfortable publishing.
The practical lesson
Most application developers will never choose ARC entry points manually.
That is not really the point.
The useful lesson is about how to reason about compiler guarantees.
Swift 6 rejecting a data race does not mean every subsystem now gets to assume that the entire lifetime of every involved object is single-threaded.
Concurrency safety is one contract.
Object lifetime accounting is another.
Sometimes a compiler can connect proofs across those boundaries.
Sometimes it cannot, or deliberately does not.
And sometimes you need to stop looking at the language feature and inspect the generated program.
That is what happened here.
Swift 6 rejected the race.
ARC stayed on the ordinary path.
Only when I explicitly told the compiler to assume a single-threaded world did the reference-counting path change.
Reproduce it
The full experiment is public:
GitHub: https://github.com/salari-dev/swift6-arc-atomicity-lab
The repository includes:
- the Swift test cases;
- the negative concurrency control;
- generated SIL, LLVM IR and assembly evidence;
- the atomic versus non-atomic A/B;
- compiler-selection tracing;
- runtime-source mapping;
- claim auditing;
- simulator validation;
- reproduction scripts.
To regenerate the compiler experiment:
./run-experiment.shFor simulator validation:
./run-simulator-validation.shThe repository preserves the frozen evidence under Evidence/, while regenerated output is written separately.
The experiment currently uses one official Apple Swift toolchain. No second official Swift toolchain was installed and tested, so cross-toolchain reproduction remains unresolved.
I would encourage you to run it rather than take this article on faith.
Compiler behaviour is exactly the kind of thing where the interesting answer may change with a future toolchain.
And if a newer Swift compiler starts lowering one of these cases differently, that would make the experiment more interesting, not less.
References
- Swift, Actor
- Swift, Sendable
- Reproduction repository: salari-dev/swift6-arc-atomicity-lab