Skip to content
AgileLogics
8 min read

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 Sendable are Sendable automatically.
  • Actors are always Sendable — they protect themselves.
  • A class is Sendable only if it's final and has only immutable (let) Sendable properties, 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 Sendable or nonisolated(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 await inside the actor. Another task can run while the method is suspended, so re-check state after each await.