An actor is a reference type whose stored properties the compiler protects from concurrent
access. Declaring actor instead of class replaces every hand-placed lock, and thread safety
becomes compile-time checked.
From Lock to Actor
Locks fail silently when you forget one withLock, and they block the calling thread. An actor
needs neither: change the keyword and delete the lock.
@dynamicMemberLookup actor Store<State, Action> {
private var state: State
private let reduce: (State, Action) -> State
init(state: State, reduce: @escaping (State, Action) -> State) {
self.state = state
self.reduce = reduce
}
subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
state[keyPath: keyPath]
}
func send(_ action: Action) {
state = reduce(state, action)
}
}
Every access from outside needs await, because another task may hold the actor.
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1_000_000 {
group.addTask { await store.send(()) }
}
}
let value = await store.value
Reentrancy Is the Trap
When actor code awaits something async, the actor suspends and lets other tasks run its isolated
code in the meantime. Every await inside an actor is a possible interleaving point, so an
actor is not atomic across a suspension.
actor ImageCache {
private var cache: [URL: Data] = [:]
func get(_ url: URL) async throws -> Data? {
if let data = cache[url] { return data }
let data = await download(url) // second caller can enter here
cache[url] = data
return data
}
}
Two callers asking for the same URL both miss the cache and both download. The fix is to store
the in-flight Task itself, so the check and the reservation happen with no await between them.
actor ImageCache {
private var cache: [URL: Task<Data, Never>] = [:]
func get(_ url: URL) async throws -> Data? {
if let task = cache[url] { return await task.value }
let task = Task { await download(url) }
cache[url] = task
return await task.value
}
}
Default to actors; reach for locks only when you need thread safety outside an async context.