Actors and data-race safety
How Swift 6 turns data races from a debugging nightmare into a compile error — and what that costs you.
By the end you will be able to
- Explain actor isolation and what `await` on an actor method really means
- Say what `Sendable` requires and why value types satisfy it easily
- Use @MainActor correctly and explain Swift 6.2's default-isolation setting
Two tasks increment a shared class Counter { var value = 0 } at the same time, a thousand times each. In Swift 6 with strict concurrency, what happens?
The problem
class Counter {
var value = 0
func increment() { value += 1 }
}
value += 1 is a read, an add, and a write. Two threads interleaving those steps lose updates. The traditional fix is a lock, which works only if every access remembers to take it — and nothing checks that you did.
An actor is a type that protects its own state
actor Counter {
private var value = 0
func increment() -> Int {
value += 1
return value
}
var current: Int { value }
}
An actor guarantees that only one task at a time executes its code. Its state is isolated: from outside, you can only reach it through await.
let counter = Counter()
let newValue = await counter.increment() // await is required
That await is doing real work. It means "suspend until the actor is free, then run this on the actor". You are not waiting on the operation being slow — you are waiting for exclusive access.
Five concurrent deposits, and the total is exactly 500. No lock was written.
Isolation, in one rule
Inside the actor, its own state is reachable synchronously — no await needed, because you are already on the actor.
Outside, every access must await, and you can only reach let constants directly.
actor Cache {
private var storage: [String: Data] = [:]
func value(for key: String) -> Data? {
storage[key] // no await: already isolated
}
}
let data = await cache.value(for: "k") // await: crossing into the actor
nonisolated opts a member out, for things that touch no mutable state:
actor Downloader {
let identifier: String
nonisolated var description: String { "Downloader \(identifier)" }
}
Reentrancy — the sharp edge
Actors protect against simultaneous execution, not against interleaving at suspension points. When an actor method awaits, the actor is released and another task may run on it.
actor ImageLoader {
private var cache: [URL: Image] = [:]
func load(_ url: URL) async throws -> Image {
if let cached = cache[url] { return cached }
let image = try await download(url) // ← actor released here
cache[url] = image
return image
}
}
Two concurrent calls for the same URL both miss the cache, both download. Not a data race — the state is still consistent — but wasted work. The fix is to cache the in-flight Task, not just the result:
private var inFlight: [URL: Task<Image, Error>] = [:]
func load(_ url: URL) async throws -> Image {
if let existing = inFlight[url] { return try await existing.value }
let task = Task { try await download(url) }
inFlight[url] = task
return try await task.value
}Sendable — what may cross a boundary
A type is Sendable if it is safe to pass between concurrent contexts. The rules:
- Value types whose members are all
Sendable— automatic. This is why structs and enums make concurrency easy. - Actors — always, because they protect themselves.
- Immutable classes (
finalwith onlyletproperties ofSendabletype) — you can declare it. - Mutable classes — not
Sendable, unless you enforce safety yourself and declare@unchecked Sendable, which is a promise the compiler cannot verify.
Closures crossing a boundary must be @Sendable, which means everything they capture must be Sendable too.
The practical consequence: model your data as structs and enums and most of this simply does not come up.
@MainActor
The main actor is a global actor for the UI. Anything touching UIKit or SwiftUI view state must run there — and the reason is the same data race this lesson opened with, aimed at the screen: the frameworks read your view state mid-render without locks, so a background-thread write can land halfway through a render pass — a torn frame, a layout crash, or an update the screen never shows. That is what the Main Thread Checker's purple runtime warning is announcing: a data race with pixels.
@MainActor
final class SearchModel {
var results: [Result] = []
func search(_ query: String) async {
let found = await api.search(query) // off the main actor
results = found // back on it, safely
}
}
Marking the whole type is usually better than marking individual members: fewer hops, and no way to forget one.
Swift 6.2: approachable concurrency
Strict concurrency in Swift 6.0 required a great many annotations for an app that is mostly single-threaded UI code. Swift 6.2 addressed that with three changes worth knowing:
- Default actor isolation. Setting a module's default isolation to
MainActormeans every type in it is main-actor-isolated unless you say otherwise. For an app target that is exactly right — most of your code is UI code — and it removes most of the annotation burden. @concurrentmarks a function that should genuinely run off the current actor. It is the explicit opt-out: heavy decoding, image processing, parsing.nonisolated(nonsending)makes anonisolated asyncfunction run on the caller's executor rather than hopping to the global pool. The hop it removes, concretely: a main-actor view model awaits an innocentnonisolated asyncformatting helper — and the helper's body executes off the main actor, a thread hop nobody asked for, invisible until an isolation assertion (or a UIKit call inside the helper) trips over it.
The shape this gives you: start on the main actor, escape deliberately. That is a much better default than starting concurrent and trying to prove safety afterwards.
@MainActor
final class Model {
var items: [Item] = []
}
@MainActor
struct ContentView: View {
@State private var model = Model()
var body: some View { … }
}
@MainActor
func present(_ item: Item) { … }// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
final class Model {
var items: [Item] = []
}
struct ContentView: View {
@State private var model = Model()
var body: some View { … }
}
@concurrent
func decode(_ data: Data) async -> Item { … }Same guarantees. The right-hand version states the exception (@concurrent for the genuinely parallel work) instead of stating the rule over and over.
An actor serialises access. Given the deliberate delay inside bump, what does this print?
actor Counter {
private var value = 0
func bump(_ label: String) async -> Int {
let start = value
try? await Task.sleep(for: .milliseconds(10))
value = start + 1
print("\(label) saw \(start), wrote \(value)")
return value
}
}
func run() async {
let counter = Counter()
async let a = counter.bump("A")
async let b = counter.bump("B")
_ = await [a, b]
}
await run()Why are structs so much easier to work with under strict concurrency than classes?
Make it an actor
Convert this class into an actor so concurrent updates are safe, and print the final total.
Fix the reentrancy bug
bump loses updates because it awaits between reading and writing. Fix it so the final value is 3.
Explain to someone who has used locks why an actor is not simply "a class with a lock around every method". Your answer should mention reentrancy and what the compiler checks that a lock cannot.
You can now:
- Explain actor isolation and what
awaiton an actor really waits for - Recognise and fix a reentrancy bug
- Say what
Sendablerequires and why value types make it easy - Use
@MainActorand explain Swift 6.2's default-isolation model
Next up: Liquid Glass and the SwiftUI shipped in iOS 26 and 27.