Replace Locks with Swift Actors for Shared State
Use Swift actors and async/await to protect mutable shared state instead of manual locks, with compiler-enforced isolation and Sendable checks.
26 Sept 2026, 04:07 UTC

A lock-protected counter still lets races slip through
Imagine a shared `UserSessionCache` that stores active sessions by user ID. Two background tasks refresh profiles at the same time, and both read, modify, and write the same dictionary. Wrapping the dictionary in an `NSLock` works only if every caller remembers to lock and unlock. A missed `unlock()` deadlocks the queue; a missed `lock()` corrupts the dictionary. The compiler cannot help because the lock is a runtime convention, not a type-system rule.
Swift actors move that convention into the language. An actor is a reference type that owns mutable state and serializes access to it. When you call a method on an actor from outside, the call is asynchronous: the caller suspends at an `await` point instead of blocking a thread, and the actor processes requests one at a time through an internal mailbox.
How actor isolation enforces serialization
Actor isolation means code inside the actor can touch its state directly, while code outside must go through isolated methods and await the result. The compiler tracks which actor a piece of code runs on and rejects direct access to another actor's state. This is different from a dispatch queue: the queue serializes work, but it cannot stop you from reading the protected variable from the wrong thread.
Values that cross actor boundaries must be `Sendable`, a protocol indicating a value is safe to share between concurrent contexts. Under strict concurrency checking, the compiler warns when you pass a non-`Sendable` type across that boundary.
Actors are reentrant at suspension points. If an actor method hits an `await`, another task can enter the actor and run before the first method resumes. Your invariants must still hold when execution continues.
Worked example: replace a lock with an actor
Here is a lock-based cache and its actor replacement. Run this in a Swift target built with Swift 5.5 or later (Xcode 13 or later).
// Lock-based version
final class SessionCache {
private var sessions: [String: String] = [:]
private let lock = NSLock()
func session(for userId: String) -> String? {
lock.lock()
defer { lock.unlock() }
return sessions[userId]
}
func store(_ token: String, for userId: String) {
lock.lock()
defer { lock.unlock() }
sessions[userId] = token
}
}
// Actor-based version
actor ActorSessionCache {
private var sessions: [String: String] = [:]
func session(for userId: String) -> String? {
sessions[userId]
}
func store(_ token: String, for userId: String) {
sessions[userId] = token
}
}
// Usage
let cache = ActorSessionCache()
Task {
await cache.store("token-a", for: "user-1")
print(await cache.session(for: "user-1") ?? "none")
}
Task {
await cache.store("token-b", for: "user-1")
print(await cache.session(for: "user-1") ?? "none")
}
The two `Task` blocks run concurrently, but the actor serializes the `store` and `session` calls. You should not see interleaved dictionary mutations because only one actor method executes at a time. The caller suspends at each `await`; no thread is blocked waiting for the cache.
Enable strict concurrency and check warnings
- In Xcode, open Build Settings for your target.
- Set Swift Compiler - Concurrency > Strict Concurrency Checking to
Complete(Swift 6 mode) orTargetedfor Swift 5.5+ projects adopting actors incrementally. - Run
swift buildfrom the project directory, or pressCmd+Bin Xcode. - Review actor-isolation warnings. A warning such as "accessing actor-isolated property outside of actor context" means a caller is touching state without `await`.
Required permissions: none beyond normal project access. Risk: enabling strict concurrency can surface pre-existing data races in older code or dependencies, so enable it on a branch and fix warnings before merging.
Limitations
- Reentrancy: if an actor method awaits work and relies on state remaining unchanged, another actor call can mutate that state first. Re-check invariants after every `await`.
- Non-`Sendable` types: passing a class or closure across actor boundaries may trigger warnings. Make the type `Sendable` or copy the data before crossing.
- UIKit and AppKit: most UI work must run on `MainActor`. Mark view controllers or UI updates with `@MainActor` rather than mixing manual `DispatchQueue.main.async` calls.
Practical check
After converting a type to an actor, build with strict concurrency enabled. If the build is clean and calls from `Task` blocks produce serialized console output without crashes or corrupted state, the migration is working. If you see isolation warnings, add `await` at the call site or move the offending code inside the actor.
Actors do not make every concurrency problem disappear, but they turn shared-state discipline into a compile-time check. Start with one small mutable cache or counter, convert it to an actor, and let the compiler find the callers that were previously guessing about locks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.