Actors and data-race safety · advanced

Actors and data-race safety

How Swift 6 turns data races from a debugging nightmare into a compile error — and what that costs you.

13 min read14 min practice0/2 exercises4 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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.

Swift

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 (final with only let properties of Sendable type) — 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 MainActor means 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.
  • @concurrent marks a function that should genuinely run off the current actor. It is the explicit opt-out: heavy decoding, image processing, parsing.
  • nonisolated(nonsending) makes a nonisolated async function 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 innocent nonisolated async formatting 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.

Swift 6.0: annotate everything
@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 6.2: main-actor by default
// 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.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

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()
Check yourself

Why are structs so much easier to work with under strict concurrency than classes?

Make it an actor

Write the code

Convert this class into an actor so concurrent updates are safe, and print the final total.

Solution unlocks after 3 attempts

Fix the reentrancy bug

Write the code

bump loses updates because it awaits between reading and writing. Fix it so the final value is 3.

Solution unlocks after 3 attempts
Explain itSaved on this device. Never graded.

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.

Checkpoint

You can now:

  • Explain actor isolation and what await on an actor really waits for
  • Recognise and fix a reentrancy bug
  • Say what Sendable requires and why value types make it easy
  • Use @MainActor and explain Swift 6.2's default-isolation model

Next up: Liquid Glass and the SwiftUI shipped in iOS 26 and 27.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.