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
actorKeyword - Actor Isolation in Practice
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
nonisolatedmethod or property cannot access actor-isolated stored properties. The compiler will reject any attempt.nonisolatedis 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 Sendableopts 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
awaitinside 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)
| Scenario | Recommendation |
|---|---|
| Shared mutable state accessed from multiple tasks or threads | actor — 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 path | class with manual locking — avoid actor hop overhead |
| Immutable data passed between tasks | Value types (struct) conforming to Sendable — no actor needed |
| Domain-level serialization (database, file I/O) isolated from UI | Custom @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
actoris 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. nonisolatedmethods and properties can be called synchronously when they do not touch mutable actor state.@MainActoris a global actor that pins work to the main thread, replacing manualDispatchQueue.main.asynccalls with compile-time guarantees.- Actor reentrancy means state can change at every
awaitpoint inside an actor — snapshot mutable state before suspensions and use guard flags for re-entrant protection. Sendableis the compile-time marker for types safe to cross concurrency boundaries; use@unchecked Sendableonly 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.