6. Actors, Sendable & data-race safety¶
What & why¶
A data race happens when two threads access the same memory at the same time and at least one writes. The results are corrupt data and heisenbugs. Swift 6's language mode makes data races a compile-time error, using three tools:
- Actors protect mutable state by allowing only one task to touch it at a time.
Sendablemarks types whose values are safe to pass between concurrency domains.- Isolation annotations (
@MainActor,nonisolated) say which domain code runs in.
The problem¶
final class Counter {
var value = 0
func increment() { value += 1 }
}
let counter = Counter()
// 1000 tasks calling counter.increment() concurrently => lost updates, undefined behaviour.
Locks fix this, but nothing forces you to use them. Actors make the right thing the only thing.
Actors¶
An actor is a reference type that serializes access to its mutable state:
actor Counter {
private(set) var value = 0
func increment() { value += 1 }
func add(_ n: Int) { value += n }
}
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1_000 {
group.addTask { await counter.increment() }
}
}
print(await counter.value) // always 1000
Rules:
- Code inside the actor accesses its state synchronously.
- Code outside must
await— the call may need to wait for its turn (it's a suspension point). - Actors process one message at a time; they never run two of their methods simultaneously.
Reentrancy: the subtle part¶
When an actor method hits an await, other calls can run on that actor in the meantime. State may change across suspension points:
actor ImageCache {
private var images: [URL: Data] = [:]
func image(for url: URL) async throws -> Data {
if let cached = images[url] { return cached }
let data = try await download(url) // suspension: another caller may start the same download
images[url] = data // ...so we may overwrite theirs
return data
}
}
func download(_ url: URL) async throws -> Data { Data() }
Fix: store the in-flight Task so concurrent callers share it:
actor ImageCache2 {
private var inFlight: [URL: Task<Data, Error>] = [:]
private var images: [URL: Data] = [:]
func image(for url: URL) async throws -> Data {
if let cached = images[url] { return cached }
if let task = inFlight[url] { return try await task.value }
let task = Task { try await download(url) }
inFlight[url] = task
defer { inFlight[url] = nil }
let data = try await task.value
images[url] = data
return data
}
}
Rule: don't assume actor state is unchanged across an await. Finish state mutation before suspending, or re-check afterwards.
nonisolated and actor members¶
actor Account {
let id: String // `let` of a Sendable type: readable from outside without await
private var balance = 0
init(id: String) { self.id = id }
nonisolated var description: String { "Account(\(id))" } // doesn't touch mutable state
func deposit(_ amount: Int) { balance += amount }
func balanceSnapshot() -> Int { balance }
}
The main actor¶
@MainActor is a global actor representing the main thread. UI frameworks require UI code to run there:
@MainActor
final class ViewModel {
var title = "" // all members are main-actor-isolated
func load() async {
let text = await fetchTitle() // suspends; main thread stays free
title = text // back on the main actor
}
nonisolated func pureHelper() -> Int { 42 }
}
func fetchTitle() async -> String { "Hello" }
Apply @MainActor to a whole type, a function, or a closure (Task { @MainActor in ... }). Cross-isolation calls from elsewhere need await.
You can define your own global actors for subsystems (@globalActor actor DatabaseActor { static let shared = DatabaseActor() }).
Sendable¶
A type is Sendable if values can be safely used from multiple concurrency domains at once. The compiler enforces it whenever a value crosses a boundary: arguments to actor methods, task closure captures, and results of async calls.
What qualifies:
| Type | Sendable? |
|---|---|
Int, String, Bool, Date, URL, UUID |
yes |
| Struct/enum whose stored properties are all Sendable | yes (implicit for non-public types) |
| Actors | yes — they protect themselves |
Final class with only immutable (let) Sendable properties |
yes, if declared Sendable |
| Class with mutable properties | no, unless internally synchronized |
| Closure | only if marked @Sendable and captures only Sendable values |
struct Point: Sendable { var x, y: Double } // value type: copy per domain, safe
final class Config: Sendable { // immutable class
let name: String
init(name: String) { self.name = name }
}
final class Cache: @unchecked Sendable { // YOU promise it's thread-safe
private let lock = NSLock()
private var storage: [String: Int] = [:]
func get(_ key: String) -> Int? { lock.lock(); defer { lock.unlock() }; return storage[key] }
func set(_ key: String, _ value: Int) { lock.lock(); defer { lock.unlock() }; storage[key] = value }
}
@unchecked Sendable turns off the compiler's check. It is a promise you must keep with locks or atomics; treat it as unsafe and document why it's correct. Prefer actors or the Mutex type from the Synchronization module:
import Synchronization
final class SafeCounter: Sendable {
private let state = Mutex(0)
func increment() { state.withLock { $0 += 1 } }
var value: Int { state.withLock { $0 } }
}
Mutex (Swift 6, standard library) is a fast lock for synchronous critical sections: no await inside, no actor hop — ideal for tiny shared counters and caches.
@Sendable closures and capturing¶
var total = 0
Task {
// total += 1 // ERROR in Swift 6: mutation of captured var 'total' in concurrently-executing code
}
Fixes: move the state into an actor, return values from tasks and combine them in one place (as the capstone does), or use a Mutex.
sending (region-based isolation)¶
A non-Sendable value can cross domains safely if the sender gives up all access to it. Swift 6 tracks that with regions and the sending keyword:
final class Document { var text = "" } // not Sendable
func makeAndProcess() async {
let doc = Document()
doc.text = "hello"
await process(doc) // OK: `doc` is never used again here, so ownership is transferred
// print(doc.text) // ERROR if uncommented: used after being sent
}
func process(_ doc: sending Document) async { print(doc.text) }
This removes a lot of false positives from earlier strict-concurrency checks.
Adopting Swift 6 and "approachable concurrency"¶
- Swift 6 language mode (
swift-tools-version: 6.0) turns data-race diagnostics from warnings into errors. Existing projects can migrate target by target withswiftLanguageModes: [.v5]for stragglers, and turn on-strict-concurrency=completewarnings first. - Swift 6.2 added "approachable concurrency" settings: an option to default a module's code to
@MainActorisolation (great for apps and scripts: fewer annotations), andnonisolated(nonsending)async functions that run on the caller's actor by default, with@concurrentas the explicit way to say "hop to the global pool". Check your toolchain's documentation for the exact settings your version supports; the direction of travel is "single-threaded by default, opt in to parallelism".
Design guidance¶
- Prefer value types and
Sendablestructs; they eliminate most sharing. - Confine mutable state to one actor (or the main actor) and send it messages (async calls with Sendable arguments).
- Return results instead of sharing: tasks compute independent values; one place merges them.
- Use a
Mutexfor tiny, synchronous critical sections; use an actor when operations involveawaitor the state is a subsystem. - Keep actor methods short and synchronous where possible; long awaits invite reentrancy bugs.
- Don't use
@unchecked Sendableto quiet the compiler. Fix the design.
Common mistakes¶
- Assuming actor methods are atomic across
awaits (reentrancy). - Making everything an actor — a struct returned from a task is often simpler and faster.
- Using
DispatchQueue.main.asyncmixed with@MainActor— pick one model. - Capturing a mutable class in
Task { }and "fixing" the warning with@unchecked Sendable. - Awaiting heavy work on the main actor, blocking the UI. Hop off with a nonisolated/
@concurrentasync function or a detached task. - Doing blocking I/O inside actors, which stalls everything waiting on them.
Exercises¶
- Write the unsafe
Counterclass, hammer it from a task group, and observe wrong totals. Replace it with anactorand with aMutex. - Implement a
BankAccountactor withtransfer(to:amount:). Think about what happens if two transfers run in opposite directions — does reentrancy bite? - Build the
ImageCachewith in-flight task coalescing and prove with a counter that concurrent requests for the same URL download once. - Create a non-
Sendableclass, hit the compiler error when sending it into aTask, then fix it three ways (make it a struct, make it an actor, mark itfinalwithletproperties). - Write a
@MainActorview model and a backgroundactordata source; load data and update the view model. - Turn on strict concurrency in an old package and fix every diagnostic you can without
@unchecked.
Recap¶
- Actors serialize access to mutable state; calling in from outside is
await. - Actors are reentrant: state can change across any
await. Sendableguards what may cross concurrency boundaries;@unchecked Sendableis a promise you must keep.@MainActorisolates UI work;Mutexis for tiny synchronous critical sections.- Swift 6 makes data races compile errors — design with values, messages, and merged results.