A plain class shared across queues needs manual synchronization — a serial DispatchQueue, an NSLock, or os_unfair_lock — and forgetting to wrap even one access to a mutable property is a data race the compiler won't catch until it crashes in production.
#The old way
final class ImageCache {
private let queue = DispatchQueue(label: "com.app.imagecache")
private var storage: [String: UIImage] = [:]
func image(for key: String) -> UIImage? {
queue.sync { storage[key] }
}
func insert(_ image: UIImage, for key: String) {
queue.sync { storage[key] = image }
}
}
#The new way
actor ImageCache {
private var storage: [String: UIImage] = [:]
func image(for key: String) -> UIImage? {
storage[key]
}
func insert(_ image: UIImage, for key: String) {
storage[key] = image
}
}
func loadThumbnail(key: String, cache: ImageCache) async -> UIImage? {
if let cached = await cache.image(for: key) {
return cached
}
let image = UIImage()
await cache.insert(image, for: key)
return image
}
#Why it matters
- The compiler enforces the
awaitat every call site that touches actor state from outside — you cannot forget the synchronization boundary - No manual queue or lock to create, name, and remember to use consistently
- Actor state is isolated by construction; there is no "storage" accessible without going through the actor's interface
- Methods called from inside the actor itself run synchronously against its own state — no
awaitneeded forself
#Gotcha
Actors are reentrant: when a method suspends at an await, other calls into the same actor can run before the first one resumes, and the actor's state can change out from under you across that suspension point. Code that reads a value, awaits something, then acts on that value based on an assumption it's still true needs to re-check state after the await, not just before it.