Actors in Swift: Eliminating Data Races at Compile Time


Thread Sanitizer found a data race in your production app. It is 3 AM, the on-call alert is firing, and you are staring at a crash log showing two threads simultaneously mutating the same object. Swift actors would have caught this at compile time — before the app ever shipped.

This guide covers the actor keyword, actor isolation, @MainActor, global actors, Sendable, and the reentrancy trap that catches even experienced engineers. We will not cover TaskGroup or AsyncSequence — those have dedicated posts.

Contents

The Problem

The classic data race: two threads append to the same array without synchronization.

// ❌ Thread-unsafe class — classic data race waiting to happen
class RenderQueue {
    var jobs: [RenderJob] = []
    var activeJobIDs: Set<UUID> = []

    func enqueue(_ job: RenderJob) {
        jobs.append(job) // Thread A writes here
    }

    func dequeue() -> RenderJob? {
        guard !jobs.isEmpty else { return nil }
        return jobs.removeFirst() // Thread B reads and mutates simultaneously
    }

    func markActive(_ id: UUID) {
        activeJobIDs.insert(id) // Another unsynchronized mutation
    }
}

// Somewhere in the app, on two different queues:
DispatchQueue.global().async { renderQueue.enqueue(job1) }
DispatchQueue.global().async { _ = renderQueue.dequeue() }

The traditional fix is a DispatchQueue or NSLock — a runtime solution that the compiler cannot verify. You can forget to acquire the lock in a new method, pass the object to a different queue, or use the wrong lock. Thread Sanitizer will tell you when something goes wrong in testing. Swift actors tell you at compile time.

The actor Keyword

An actor is a reference type — like a class — that the Swift compiler enforces serial access to. Every stored property inside an actor is actor-isolated: only one task can access them at a time. No lock required, no queue juggling, no runtime crashes.

// ✅ Actor: the compiler enforces serial access to all mutable state
actor RenderFarm {
    private var queue: [PixarJob] = []
    private var activeJobs: Set<UUID> = []

    func enqueue(_ job: PixarJob) {
        queue.append(job)
    }

    func dequeue() -> PixarJob? {
        guard !queue.isEmpty else { return nil }
        return queue.removeFirst()
    }

    func markActive(_ id: UUID) {
        activeJobs.insert(id)
    }

    var queueCount: Int {
        queue.count
    }
}

This looks nearly identical to the class version. The difference is what happens when you try to call it from outside the actor:

let farm = RenderFarm()

// ❌ Compile error: actor-isolated property 'queue' can not be
// referenced from outside the actor — you must await
let count = farm.queueCount

// ✅ Correct: cross-actor calls require await
let count = await farm.queueCount
let job = await farm.dequeue()
await farm.enqueue(PixarJob(title: "Finding Nemo — Scene 47"))

The await tells the compiler (and the programmer) that this call may suspend the current task while waiting for the actor’s serial executor to become available. The compiler rejects any attempt to access actor-isolated state without await. There is no way to accidentally write the race condition from the class version — it simply will not compile.

Actor Isolation in Practice

nonisolated for Pure Computed Properties

Not every member of an actor needs actor isolation. Computed properties that read only immutable state — or instance properties that are themselves immutable — can be marked nonisolated to allow synchronous, non-awaited access.

actor RenderFarm {
    private var queue: [PixarJob] = []
    let farmName: String // Immutable — safe to access without isolation

    init(name: String) {
        self.farmName = name
    }

    // nonisolated: no mutable state accessed, callable without await
    nonisolated var description: String {
        "Pixar Render Farm: \(farmName)"
    }

    // nonisolated: implementing Equatable based on identity
    nonisolated static func == (lhs: RenderFarm, rhs: RenderFarm) -> Bool {
        lhs.farmName == rhs.farmName
    }
}

// No await required
let farm = RenderFarm(name: "Emeryville")
print(farm.description) // "Pixar Render Farm: Emeryville"

Warning: A nonisolated method or property cannot access actor-isolated stored properties. The compiler will reject any attempt. nonisolated is for logic that is genuinely independent of mutable actor state.

Actor Initializers

An actor’s init runs in the isolated context of the actor itself. You can access stored properties directly without await. This changes post-Swift 6 for delegating initializers, but for regular init, direct access works as expected.

actor RenderFarm {
    private var queue: [PixarJob]
    private let maxConcurrentJobs: Int

    init(maxConcurrentJobs: Int = 4) {
        // Direct access inside init — no await needed
        self.queue = []
        self.maxConcurrentJobs = maxConcurrentJobs
    }
}

Advanced Usage

@MainActor — The Global Actor for UI Work

@MainActor is a global actor that isolates work to the main thread. Apply it to a class, struct, or individual method to guarantee that all accesses happen on the main thread — enforced by the compiler.

// Apply to an entire class — all methods run on the main thread
@MainActor
class FilmListViewModel: ObservableObject {
    @Published var films: [PixarFilm] = []
    @Published var isLoading = false

    func loadFilms() async {
        isLoading = true
        do {
            // Suspension point — we may hop off the main thread here,
            // but the assignment back is guaranteed to run on MainActor
            films = try await filmService.fetchAll()
        } catch {
            print("Failed to load Pixar catalog: \(error)")
        }
        isLoading = false
    }
}

Apply @MainActor to individual methods when you only need one part of a type to be main-thread-safe:

class FilmDetailViewController: UIViewController {
    // This method is guaranteed to run on the main thread
    @MainActor
    func updatePosterImage(_ image: UIImage) {
        posterImageView.image = image
    }
}

// Caller must await
await viewController.updatePosterImage(downloadedPoster)

Defining Custom Global Actors

You can define your own global actor using @globalActor. This is useful for isolating all database work to a single serial context, separate from the main thread.

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

// All database operations are serialized through DatabaseActor
@DatabaseActor
func saveToDisk(_ film: PixarFilm) async throws {
    try persistenceLayer.save(film)
}

@DatabaseActor
class FilmRepository {
    func insert(_ film: PixarFilm) throws { ... }
    func delete(id: String) throws { ... }
}

Note: Custom global actors are a Swift 5.5+ feature documented in SE-0316. They are useful for database layers, file I/O coordinators, and any domain where you want compile-time enforcement of serial access across unrelated types.

Sendable — Safe Concurrency Boundaries

Sendable is the Swift protocol that marks types as safe to share across concurrency boundaries — between tasks and across actor hops. The compiler checks Sendable conformance when you pass values into tasks or across actor isolation boundaries.

// Value types with Sendable members are automatically Sendable
struct PixarJob: Sendable {
    let id: UUID
    let filmTitle: String
    let sceneNumber: Int
}

// A class can be Sendable if it is truly immutable
final class RenderConfiguration: Sendable {
    let resolution: CGSize
    let colorSpace: CGColorSpace

    init(resolution: CGSize, colorSpace: CGColorSpace) {
        self.resolution = resolution
        self.colorSpace = colorSpace
    }
}

When you have a class that manages its own thread safety internally — perhaps wrapping a C library with its own locking — you can use @unchecked Sendable to silence the compiler check. Do this only when you can manually verify safety:

// Use @unchecked Sendable only when you manage thread safety yourself
final class LegacyRenderBridge: @unchecked Sendable {
    private let lock = NSLock()
    private var frameBuffer: [UInt8] = []

    func appendFrame(_ bytes: [UInt8]) {
        lock.withLock { frameBuffer.append(contentsOf: bytes) }
    }
}

Warning: @unchecked Sendable opts out of compiler verification entirely. If your manual locking has a bug, Swift will not catch it. Use it only as a last resort for bridging legacy synchronization code.

Actor Reentrancy — The Subtle Trap

Actor isolation guarantees serial access, but it does not make actors atomic end-to-end. When an actor-isolated method hits an await point, the actor’s executor can run other work in the gap. By the time the suspended method resumes, the actor’s state may have changed.

actor RenderFarm {
    private var queue: [PixarJob] = []

    func processNextJob() async {
        // ⚠️ Reentrancy hazard: queue might be empty by the time we resume
        guard !queue.isEmpty else { return }
        let job = queue.removeFirst() // Snapshot at this point

        // Actor is released here — another task can call processNextJob()
        // and remove jobs during this await
        await renderEngine.process(job)

        // When we resume, other tasks may have also started jobs
        // The state here is not the same as before the await
        queue.append(job.nextJob ?? job) // Is this still valid?
    }
}

The fix is to make state mutations atomic relative to each suspension point — read and remove state before any await, and do not assume state is unchanged after one.

actor RenderFarm {
    private var queue: [PixarJob] = []
    private var isProcessing = false

    func processNextJob() async {
        // Guard using a flag that prevents re-entry
        guard !isProcessing, !queue.isEmpty else { return }
        isProcessing = true
        let job = queue.removeFirst() // Remove before await

        await renderEngine.process(job) // Actor released here

        isProcessing = false // Reset after resuming
    }
}

Warning: Actor reentrancy is one of the most common sources of bugs when migrating to Swift concurrency. Always audit what state could change at every await inside an actor method.

Performance Considerations

Actors use a serial executor rather than a lock. The distinction matters: a DispatchQueue-based lock blocks the calling thread while waiting; an actor suspends the calling task and returns the thread to the pool. This makes actors significantly more efficient under high concurrency, because blocked threads waste CPU and stack memory.

Apple Docs: Actor — Swift Standard Library

The tradeoff is hop overhead. Every cross-actor call requires a task suspension and resume, even if the actor’s executor is idle. This is negligible for typical UI or networking code but can accumulate if you make thousands of actor calls per second in a hot loop. In those scenarios, batch your work into a single actor call rather than calling the actor once per element.

// ❌ 1000 individual actor hops
for job in jobs {
    await farm.enqueue(job)
}

// ✅ One hop, batch operation
await farm.enqueue(contentsOf: jobs)

When to Use (and When Not To)

ScenarioRecommendation
Shared mutable state accessed from multiple tasks or threadsactor — compile-time safety, zero boilerplate
All UI state updates must happen on the main thread@MainActor class or @MainActor method annotation
Shared mutable state in a performance-critical, single-threaded pathclass with manual locking — avoid actor hop overhead
Immutable data passed between tasksValue types (struct) conforming to Sendable — no actor needed
Domain-level serialization (database, file I/O) isolated from UICustom @globalActor applied to the relevant layer
Legacy C/ObjC types with their own threading guarantees@unchecked Sendable as a last resort; document the invariants

Summary

  • actor is a reference type that enforces serial access to its mutable state at compile time — no locks, no queues, no data races.
  • Cross-actor calls require await; the compiler rejects synchronous access to actor-isolated state, making races impossible to write accidentally.
  • nonisolated methods and properties can be called synchronously when they do not touch mutable actor state.
  • @MainActor is a global actor that pins work to the main thread, replacing manual DispatchQueue.main.async calls with compile-time guarantees.
  • Actor reentrancy means state can change at every await point inside an actor — snapshot mutable state before suspensions and use guard flags for re-entrant protection.
  • Sendable is the compile-time marker for types safe to cross concurrency boundaries; use @unchecked Sendable only when bridging manually synchronized legacy code.

Actors protect mutable state in place, but sometimes you need a stream of values that arrives over time rather than all at once. The next step is AsyncSequence and AsyncStream — processing data as it flows.