Skip to content

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.
  • Sendable marks 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 with swiftLanguageModes: [.v5] for stragglers, and turn on -strict-concurrency=complete warnings first.
  • Swift 6.2 added "approachable concurrency" settings: an option to default a module's code to @MainActor isolation (great for apps and scripts: fewer annotations), and nonisolated(nonsending) async functions that run on the caller's actor by default, with @concurrent as 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

  1. Prefer value types and Sendable structs; they eliminate most sharing.
  2. Confine mutable state to one actor (or the main actor) and send it messages (async calls with Sendable arguments).
  3. Return results instead of sharing: tasks compute independent values; one place merges them.
  4. Use a Mutex for tiny, synchronous critical sections; use an actor when operations involve await or the state is a subsystem.
  5. Keep actor methods short and synchronous where possible; long awaits invite reentrancy bugs.
  6. Don't use @unchecked Sendable to 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.async mixed 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/@concurrent async function or a detached task.
  • Doing blocking I/O inside actors, which stalls everything waiting on them.

Exercises

  1. Write the unsafe Counter class, hammer it from a task group, and observe wrong totals. Replace it with an actor and with a Mutex.
  2. Implement a BankAccount actor with transfer(to:amount:). Think about what happens if two transfers run in opposite directions — does reentrancy bite?
  3. Build the ImageCache with in-flight task coalescing and prove with a counter that concurrent requests for the same URL download once.
  4. Create a non-Sendable class, hit the compiler error when sending it into a Task, then fix it three ways (make it a struct, make it an actor, mark it final with let properties).
  5. Write a @MainActor view model and a background actor data source; load data and update the view model.
  6. 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.
  • Sendable guards what may cross concurrency boundaries; @unchecked Sendable is a promise you must keep.
  • @MainActor isolates UI work; Mutex is for tiny synchronous critical sections.
  • Swift 6 makes data races compile errors — design with values, messages, and merged results.

Next: AsyncSequence, AsyncStream & continuations →