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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
actor Counter {
    private var value = 0
    
    func increment() {
        value += 1
    }
    
    func current() -> Int {
        value
    }
}

let counter = Counter()

await counter.increment()

let current = await counter.current()

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:

1
2
3
4
5
let counter = Counter()

await counter.increment()

let current = await counter.current()

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class UnsafeCounter {
    private var value = 0
    private let lock = NSLock()
    
    func increment() {
        lock.lock()
        value += 1
        lock.unlock()
    }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
actor Logger {
    let subsystem: String
    
    init(subsystem: String) {
        self.subsystem = subsystem
    }
    
    nonisolated func subsystemName() -> String {
        subsystem
    }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
actor DataLoader {
    private var cache: [String: String] = [:]
    
    func load(_ key: String) async -> String {
        if let cached = cache[key] {
            return cached
        }
        
        let value = await fetchFromNetwork(key) // actor is now reentrant
        cache[key] = value
        return value
    }
}

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:

1
2
3
4
5
6
7
8
@MainActor
class ViewModel: ObservableObject {
    @Published var state: String = ""
    
    func updateState() {
        state = "Updated"
    }
}

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.