Skip to content
SwiftMind

Sendable & Data Races

How the compiler decides a value is safe to hand to another task.

AdvancedNot started8 min
On this page

A data race is two threads touching the same memory with at least one of them writing. The results range from a stale read to a corrupted data structure to a crash that reproduces once a fortnight. They're among the hardest bugs to find because the code is correct in isolation and wrong only in the interleaving.

Sendable is the compiler's answer: a marker protocol meaning "a value of this type can cross into another isolation domain safely". Anything you pass to a Task, send to an actor, or return from one has to be Sendable, and the compiler checks it.

This is the mechanism behind Swift 6's data-race safety. It turns a runtime bug class into a compile error.

@unchecked is you overriding the compiler. Everything else it verifies.
// Automatically Sendable: a struct of Sendable partsstruct Score: Sendable { let player: String; let points: Int }
// Not Sendable: a class with mutable state, freely shareablefinal class Counter { var value = 0 }
// Safe to share: immutable, and final so nobody adds mutable statefinal class Config: Sendable { let baseURL: URL; init(_ u: URL) { baseURL = u } }
// Safe because *you* guarantee it — the compiler stops checking herefinal class LockedCounter: @unchecked Sendable {    private let lock = NSLock()    private var _value = 0    var value: Int { lock.withLock { _value } }    func increment() { lock.withLock { _value += 1 } }}
// An actor is always Sendable: its state is protected by constructionactor Tally { private var total = 0 }
Show all 19 lines
Crossing an isolation boundary
Task Aone isolation domain
Task Banother domain
Score (Sendable)copied — safe
Counter (not Sendable)shared — rejected

A Sendable value type is copied across the boundary between two isolation domains. A non-Sendable mutable class is rejected by the compiler because both sides would share one object.

A Sendable value is copied, so each domain gets its own — no shared memory.

Step 1 of 2
  1. A Sendable value is copied, so each domain gets its own — no shared memory.
  2. A mutable class would be shared by reference, so the compiler refuses.

Under the hood

Sendable has no requirements — it's a marker protocol, and conformance is checked entirely at compile time with nothing emitted at runtime.

Conformance is inferred where it's provably safe: a struct or enum whose stored properties are all Sendable gets it automatically; actors always have it, because their state is isolated by construction; a final class with only immutable Sendable stored properties can declare it. Non-final classes can't, because a subclass could add mutable state.

@unchecked Sendable disables the check and puts the guarantee on you — appropriate when you're protecting state with a lock the compiler can't see, and a bug waiting to happen otherwise. @Sendable on a closure is the same idea for function values: it may cross domains, so everything it captures must be Sendable too.

In Swift 6 language mode these are errors. In Swift 5 mode — which this project uses — they're warnings you can enable incrementally, which is the intended migration path.

Quick check

Which of these is NOT automatically Sendable?

  • A struct whose stored properties are all Sendable
  • An actor
  • A final class with only immutable Sendable stored properties
  • A final class with a mutable var property
Reveal answer

A final class with a mutable var property

Mutable state in a class is shared by reference, which is exactly the shape a data race needs, so the compiler won't infer Sendable. Structs of Sendable parts get it automatically, actors always have it because their state is isolated, and an immutable final class is safe because nothing can change. To make (d) safe you'd protect the state with a lock and declare @unchecked Sendable, or make it an actor.

Interview question

How does Swift's concurrency model eliminate data races, and what does it still not protect you from?

Think first, then reveal

It works by dividing mutable state into isolation domains and controlling what crosses between them. Actors own their state and serialise access; @MainActor is the domain for UI. Sendable is the type-level rule for what may cross a boundary — value types of Sendable parts, immutable finals, actors, and closures whose captures are all Sendable. The compiler checks this statically, so the classic data race, two threads writing the same property with no synchronisation, becomes a compile error rather than a bug you reproduce once a fortnight.

What survives is everything above that level. High-level race conditions — logic whose correctness depends on ordering — are untouched. The canonical example is an actor cache that checks for a miss, awaits a fetch, and stores the result: perfectly data-race free, and it'll fetch twice under concurrent callers, because reentrancy lets a second call in at the await. Ordering isn't guaranteed either; callers resume when the actor is free, not in arrival order.

Deadlock is reduced rather than eliminated. Actor reentrancy removes the classic self-deadlock, but you can still block the cooperative pool — a semaphore wait or synchronous I/O inside async code occupies a thread the runtime needed, and enough of those and nothing progresses.

And every @unchecked Sendable is a hole you opened deliberately. The model is only as sound as the number of those in the codebase, which is why I treat each one as needing a comment explaining the actual guarantee.

So: a whole bug class is gone, statically. Designing correct concurrent *logic* is still the engineer's job.

What an interviewer listens for

  • Explains isolation domains and Sendable as the crossing rule
  • Distinguishes data races from race conditions with a concrete example
  • Knows reentrancy and ordering are not covered
  • Identifies pool starvation as the remaining deadlock risk
  • Treats @unchecked as an explicit soundness hole

Key takeaway

Sendable marks types safe to cross isolation boundaries, checked at compile time. Prefer immutable values and actors; treat @unchecked as a promise you must be able to explain.

Read these first

Related

0%
Quiz this concept