Hey Swifters đź‘‹
In this guide, we will explore actor
in Swift and a few other relevant topics as well. If you have dipped your toes into Swift Concurrency, you might have probably come across actor
, isolated
, and maybe even nonisolated
. They might sound intimidating at first, but they are actually your friends, especially when things get wild in concurrent code. Let’s walk through:
- What an actor is
- Why you’d use one
- How it solves real problems
- The roles of
nonisolated
andisolated
What Is an Actor?
An actor is a type (like a class) whose mutable state is protected from data races, and only one task can enter an actor at a time. Others wait in line. You call its methods with await
to “get in the queue.”. Why does this matter? Well, without actors, shared state needs manual locks or dispatch queues:
// Before: manual locking
class Counter {
private var value = 0
private let queue = DispatchQueue(label: "counter")
func increment() {
queue.sync { value += 1 }
}
func get() -> Int {
queue.sync { value }
}
}
Problems with this?
- Easy to forget a lock and introduce a race
- Verbose boilerplate
Actors give you built-in safety:
// After: using an actor
actor Counter {
private var value = 0
func increment() {
value += 1
}
func get() -> Int {
value
}
}
What it solves?
- No locks or queues needed
- The compiler enforces safe access
How to Use an Actor?
let counter = Counter()
Task {
// joins the actor’s queue
await counter.increment()
// also awaited
let current = await counter.get()
print("Value is \(current)")
}
So, every call that reads or writes actor state must be await
ed. Swift ensures only one such call runs at a time. So what problem does this solve? Well, imagine two tasks incrementing simultaneously:
Task { await counter.increment() }
Task { await counter.increment() }
With an actor, those calls run serially, never overlapping and no more “lost updates” or corrupted state.
What is nonisolated
?
Sometimes, you want a method on your actor that doesn’t touch any of its protected data, perhaps a formatter or a static helper. So you make it nonisolated
:
actor Logger {
nonisolated func appName() -> String {
"MyCoolApp" // this doesn’t read or write actor state
}
func log(_ message: String) {
print("[\(Date())] \(message)")
}
}
Now lets use it:
Task {
let logger = Logger()
print(logger.appName()) // âś… no `await`
await logger.log("Hello!") // âś… needs `await`
}
So this way you skip the actor queue when safety isn’t needed that makes faster calls and no await
clutter.
What is isolated
?
You’ll often see isolated
used in extensions when you need direct, synchronous access to an actor’s state but only from inside that actor. Since extensions don’t automatically get access to the actor’s internals, isolated
tells the compiler it’s safe to do so. That said, it’s not limited to extensions. You can use it in any method or helper that accepts the actor itself as a parameter marked isolated
. Just remember: such methods can only be called from within the actor. So isolated
is best suited for internal utilities, helper methods, or modular logic you want to keep clean and reusable inside your actor.
actor StatsTracker {
var total = 0
var count = 0
func add(_ value: Int) {
total += value
count += 1
}
func showAverage() {
print(average(from: self)) // âś… works, we're already inside
}
}
// Now in an extension:
extension StatsTracker {
func average(from tracker: isolated StatsTracker) -> Double {
Double(tracker.total) / Double(tracker.count)
}
}
At the callsite you cannot do await tracker.average()
from outside. You can organize your internal logic without await
inside the actor itself.
A Real-World Example
Let’s build a small UserService that fetches and caches usernames:
actor UserService {
private var cache: [Int: String] = [:]
// Actor-protected fetch
func fetchUser(id: Int) async -> String {
if let name = cache[id] {
return name
}
// Simulate network delay
try? await Task.sleep(nanoseconds: 500_000_000)
let newName = "User\(id)"
cache[id] = newName
return newName
}
// Fast metadata helper
nonisolated func serviceInfo() -> String {
"UserService v1.0"
}
}
Now at the callsite:
let service = UserService()
Task {
print(service.serviceInfo())
// fast, no await
print(await service.fetchUser(id: 42))
// safe and async
print(await service.fetchUser(id: 42))
// cached result
}
The second call is instant, actor-safe. In case of the service infor metadata doesn’t touch state, so no queue to join. Actors, nonisolated
, and isolated
give you a full toolbox for writing safe, clear, and efficient concurrent Swift code. Give them a spin next time you juggle multiple async tasks your future self will thank you! Alright so that was all for this one, see you in the next one. Happy learning. 🙂