Chapter 12 — Sendable and the data-race model
Why crossing boundaries is dangerous
flowchart LR
subgraph D1__Domain_A__a_background_task___ [\"Domain A (a background task)\"]
A["var obj: MutableThing"]
end
subgraph D2__Domain_B__the_main_actor___ [\"Domain B (the main actor)\"]
B["same MutableThing"]
end
A -->|"pass the reference"| B
A -.->|"A still holds it,
mutates it"| SHARED["one object,
two domains mutating 💥"] B -.->|"B also mutates it"| SHARED
mutates it"| SHARED["one object,
two domains mutating 💥"] B -.->|"B also mutates it"| SHARED
Sendablemeans: "a value of this type is safe to hand from one isolation domain to another without creating a data race." The compiler requiresSendableat every boundary crossing and refuses code that would send something unsafe.
What is Sendable, automatically
// Implicitly Sendable — a struct of Sendable members. Nothing to write.
struct Article {
let id: UUID
let title: String
let publishedAt: Date
}
// Public API: state the guarantee explicitly.
public struct Article: Sendable {
public let id: UUID
public let title: String
}
Making a class Sendable
// Immutable and final → safe to send.
final class ImmutableConfig: Sendable {
let baseURL: URL
let timeout: TimeInterval
init(baseURL: URL, timeout: TimeInterval) { self.baseURL = baseURL; self.timeout = timeout }
}
@unchecked Sendable: the escape hatch (handle with care)
// A class made thread-safe by a lock the compiler can't reason about.
final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var _value = 0
var value: Int { lock.withLock { _value } }
func increment() { lock.withLock { _value += 1 } }
}
- Use it only when you have real synchronization (a lock, a serial queue, atomics), not to silence an
- Prefer an
actorfirst — it gives you the same safety with compiler proof and usually less code. - Consider the
Mutextype from theSynchronizationmodule (Swift 6+) for a modern,Sendable-friendly
@Sendable closures
var counter = 0
Task {
counter += 1 // ❌ error: mutable capture of `counter` in a @Sendable closure
}
Where the compiler checks Sendable
flowchart TB
S["Sendable is required when a value…"]
S --> A["…is passed to an actor method
(crosses into the actor)"] S --> B["…is returned from an actor method
(crosses out)"] S --> C["…is captured by Task / Task.detached"] S --> D["…is passed to / returned from a task group child"] S --> E["…crosses a global-actor hop (e.g. to @MainActor)"]
(crosses into the actor)"] S --> B["…is returned from an actor method
(crosses out)"] S --> C["…is captured by Task / Task.detached"] S --> D["…is passed to / returned from a task group child"] S --> E["…crosses a global-actor hop (e.g. to @MainActor)"]
When a non-Sendable value legitimately needs to cross
What we built in this chapter
- Defined
Sendableas "safe to hand between isolation domains without a data race," and grounded it - Catalogued what's automatically
Sendable(value types ofSendablemembers,Int/String/etc., - Showed the two safe ways to make a class sendable (deeply immutable
final, or **make it an - Explained
@Sendableclosures and their no-mutable-capture rule (the error you hit in - Signposted
sending/ region-based isolation for legitimately transferring a non-Sendablevalue
Mental model to take away
Sendablemarks values safe to cross isolation boundaries. Values (copied) and actors (protected)- The compiler checks
Sendableonly at boundary crossings (into/out of actors, into tasks and task @unchecked Sendableis a promise with no enforcement — every one is a place a race can hide. Use- When a non-
Sendablevalue must legitimately move once, the answer issending(next chapter),