Understanding Actors in Swift

Published on · Updated · 21 min

Wlad
Wlad
Founder & Swift Tech Lead

Actors are at the heart of Swift's concurrency model. Introduced with Swift 5.5, they elegantly solve the data race problem by isolating mutable state. With Swift 6.2, their usage becomes even more intuitive thanks to MainActor by default. This guide covers everything you need to know to master them.

The Data Race Problem

Before Actors, protecting shared state required locks, queues, or semaphores. It was verbose and error-prone:

This code works, but the compiler cannot verify that you haven't forgotten to use the queue somewhere. A single omission is enough to create a data race.

Actor: Compiler-Guaranteed Isolation

An Actor is a reference type that automatically isolates its state. The compiler guarantees that only one call can access the state at a time:

No more queues, no more locks. The compiler takes care of everything. Each Actor maintains an internal queue (executor) that serializes access.

Accessing Actors: await Required

From outside an Actor, every access to an isolated property or method requires await:

The await keyword indicates a potential suspension point. Your code waits its turn in the Actor's queue before accessing the state.

From Inside the Actor

Inside the Actor, you access properties and methods directly without await:

nonisolated: Opting Out of Isolation

Some properties or methods don't need isolation. Use nonisolated to exempt them:

Constants declared with let are automatically nonisolated because they are immutable and therefore thread-safe.

nonisolated for Protocol Conformances

A common case: conforming an Actor to a synchronous protocol like CustomStringConvertible:

MainActor: The Main Thread Actor

MainActor is a predefined global actor that represents the main thread. All UI code must run on it:

With @MainActor on the class, all properties and methods execute on the main thread. @Published properties can be modified safely.

MainActor.run for One-Off Blocks

If you only have a small portion of code to execute on MainActor:

MainActor.assumeIsolated for Callbacks

When you know a callback will be called on the main thread but the compiler can't verify it:

Warning: assumeIsolated crashes if you're not actually on MainActor at runtime.

Custom Global Actors

You can create your own global actors to isolate specific resources:

All accesses to FileManager are now serialized on DiskActor, avoiding file access conflicts.

When to Use Global Actor vs Standard Actor

SituationRecommended Choice

Single shared instance

Global Actor

Multiple independent instances

Standard Actor

UI coordination

MainActor

Unique system resource (DB, files)

Custom Global Actor

Feature-local state

Standard Actor

Sendable: Crossing Isolation Boundaries

When data crosses Actor boundaries, it must be Sendable. The Sendable protocol guarantees that a type can be shared between concurrent contexts without data races.

Automatically Sendable Types

Some types are Sendable by default:

Explicit Conformance for Classes

Classes require special attention:

@unchecked Sendable: Use with Caution

For types with internal synchronization that the compiler can't verify:

Warning: @unchecked Sendable disables compiler checks. You are responsible for thread-safety. Prefer Mutex (iOS 18+) when possible:

@Sendable for Closures

Closures passed between concurrent contexts must be @Sendable:

Swift 6.2: MainActor by Default

Swift 6.2 introduces defaultIsolation which fundamentally changes the approach:

With this setting, all code without explicit annotation runs on MainActor:

@concurrent for Parallel Work

If you want to explicitly leave MainActor:

Isolated Conformances (SE-0470)

Swift 6.2 allows restricting a protocol conformance to an actor:

Without @MainActor Equatable, the compiler could execute == on any thread, violating the class isolation.

Actor Reentrancy

A crucial point: Actors are reentrant. When you await inside an Actor, other calls can execute while waiting:

Pattern to Avoid Reentrancy Problems

Capture necessary values before await:

Or use a transactional approach:

Architecture Patterns with Actors

Repository Actor

ViewModel with Internal Actor

Best Practices

1. Prefer Actors Over Manual Locks

2. Minimize awaits Inside Actors

Each await is a point where state can change. Group operations together:

3. Keep Actors Focused

An Actor should have a single responsibility:

Common Pitfalls

1. Excessive Actor Hopping

Switching between Actors has a cost:

2. Deadlocks with Synchronous Calls

Actors only have async methods from the outside. Watch out for designs that create circular dependencies:

3. Forgetting Computed Properties Are Isolated

Going Further

Actors are the foundation of safe concurrency in Swift. Master them before moving on to advanced concepts:

  • TaskGroup for dynamic parallelism

  • AsyncStream for asynchronous data flows

  • Custom Executors to control execution

Official Resources