In the last posts we looked at running work in parallel with async let and TaskGroup, and then how Sendable ensures the data we share across tasks is safe. The next piece of Swift’s structured concurrency story is actors. Actors provide isolation for mutable state, making them one of the core building blocks for avoiding data races.
What is an Actor?
An actor is like a class, but with one key difference: only one task at a time can interact with its mutable state. This guarantees thread safety without you having to add locks manually.
Here’s the simplest example:
| |
If multiple tasks call increment() at the same time, Swift ensures they’ll run in a safe, serialized order. You don’t need DispatchQueue, semaphores, or locks. Each call will await it’s turn.
It’s also not possible to mutate value directly. You will receive a warning of “value can not be mutated from a nonisolated context”
Isolation
When you call into an actor, you usually have to await because you’re crossing an isolation boundary:
| |
Behind the scenes, Swift ensures each call to the actor is queued and executed one at a time.
Actors vs Classes
A class with mutable state is dangerous when shared across tasks. You might be tempted to wrap access in locks:
| |
This works, but it’s easy to get wrong. Actors give you the same protection without manual locking, and with compiler support to enforce safe usage.
nonisolated Methods
Sometimes an actor has methods that don’t touch its mutable state. These can be marked as nonisolated and called without await:
| |
Because subsystem is immutable, the compiler allows it to be read without actor isolation.
One important detail with actors is reentrancy. When an actor awaits something inside one of its methods, it gives up its isolation and allows other tasks to run on it before resuming. For example:
| |
While waiting for fetchFromNetwork, other calls into DataLoader can run. This usually improves throughput, but you need to be aware of it if your logic assumes exclusivity.
Global Actors
Sometimes you want certain work to always run on a shared actor. Swift provides global actors for this. The most common is @MainActor, which ensures code runs on the main thread:
| |
Any code that touches state is automatically isolated to the main actor. This is the modern, type-safe replacement for sprinkling DispatchQueue.main.async around your code.