Actors and Data-Race Safety
Prerequisites: Structured concurrency
A data race happens when two threads access the same memory at the same time and at least one of them is writing. The result is corrupted data or crashes that happen only occasionally — some of the hardest bugs to find. Swift 6's headline feature is catching data races at compile time.
Actors
An actor is a reference type that protects its mutable state: only
one task at a time can run code inside it. Code outside must await
to access it:
actor Counter {
private var value = 0
func increment() {
value += 1
}
func current() -> Int {
value
}
}
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 1...1000 {
group.addTask { await counter.increment() }
}
}
print(await counter.current())
1000
A thousand concurrent increments, and the count is always exactly 1,000. With a plain class and no locking, some increments could be lost.
- Inside the actor, methods use its state directly — no
await. - From outside, every access is
awaited, because the caller may have to wait its turn.
Sendable
Values passed between concurrent tasks must be safe to share. Such types
are Sendable:
- Value types (structs, enums) whose properties are all
SendableareSendableautomatically. - Actors are always
Sendable— they protect themselves. - A class is
Sendableonly if it'sfinaland has only immutable (let)Sendableproperties, or protects its state internally.
In Swift 6 language mode, the compiler reports an error whenever
non-Sendable data could be shared across concurrent code:
final class Cache { // not Sendable: mutable, unprotected
var items: [String] = []
}
func fill(_ cache: Cache) async {
await withTaskGroup(of: Void.self) { group in
for i in 1...10 {
group.addTask {
cache.items.append("item \(i)") // error in Swift 6 mode
}
}
}
}
Ten child tasks would append to the same array at the same time — a
real data race. The compiler rejects it, explaining that sending
cache into concurrently-running tasks risks data races. The fixes:
make Cache an actor, make it a struct and pass copies, or have the
tasks return values and collect them in one place.
@MainActor
User interfaces must only be updated from the main thread.
@MainActor marks code that must run there, and the compiler enforces
it:
@MainActor
final class ProfileViewModel {
var name = ""
func load() async {
let fetched = await fetchName() // runs elsewhere, then returns here
name = fetched // safely back on the main actor
}
nonisolated func fetchName() async -> String {
"Ada"
}
}
SwiftUI views are main-actor-isolated, so view models used by them are
usually marked @MainActor too.
Adopting Swift 6 mode
New packages created with swift-tools-version: 6.0 use the Swift 6
language mode automatically. In an existing project, turn on complete
concurrency checking as warnings first, fix them module by module,
then switch the language mode. (Swift 6.2 also adds an option to make a
module's code run on the main actor by default, which suits many apps.)
Common mistakes
- Sprinkling
@unchecked Sendableornonisolated(unsafe)to silence errors. They turn off the compiler's safety checks — you're promising you've made the code safe yourself. - Assuming actor state doesn't change across an
awaitinside the actor. Another task can run while the method is suspended, so re-check state after eachawait.