Actors vs Locks: When Concurrency Changes Everything

The conventional wisdom says locks are faster than actors. Benchmarks tell a different story—but only under concurrency.
Follow along with the code: iOS-Practice on GitHub
The Big Insight
Actors win under concurrency. Locks win in serial code.
This isn't intuition—it's measurable. At 50,000 concurrent operations, Actor completed in 62ms while the fastest lock took 167ms. Actor was 2.8x faster.
But flip to serial execution: os_unfair_lock finished in 10.7ms while Actor took 416ms. Locks were 39x faster.
Understanding why changes how you choose synchronization primitives.
The Numbers
| Scenario | Actor | Fastest Lock | Winner |
|---|---|---|---|
| Balanced concurrent | 61.9 ms | 167.3 ms | Actor 2.8x |
| Read Heavy concurrent | 64.3 ms | 168.7 ms | Actor 2.7x |
| Write Heavy concurrent | 62.7 ms | 162.7 ms | Actor 2.7x |
| Heavy Work concurrent | 677 ms | 4,874 ms | Actor 7.5x |
| Serial (no concurrency) | 416 ms | 10.7 ms | Lock 39x |
Why Actors Dominate Concurrency
When GCD dispatches 50,000 operations to a concurrent queue, it creates threads—potentially hundreds. They all compete for the same lock:
let concurrentQueue = DispatchQueue(label: "test", attributes: .concurrent)
for i in 0..<50_000 {
concurrentQueue.async {
self.lock.lock()
defer { self.lock.unlock() }
self.values.append(i)
}
}
Result: threads spinning, context-switching, burning CPU on contention instead of work.
Swift's actor runtime uses cooperative scheduling. It limits thread count, suspends tasks efficiently at await points, and resumes them without kernel-level context switches:
await withTaskGroup(of: Void.self) { group in
for i in 0..<50_000 {
group.addTask {
await self.actor.write(i)
}
}
}
The actor processes requests serially, but the system doesn't waste resources fighting over a lock.
Why Locks Dominate Serial Code
A lock operation is a few assembly instructions:
for i in 0..<50_000 {
lock.lock()
values.append(i)
lock.unlock()
}
An actor call requires:
- Task suspension
- Executor scheduling
- Potential context switch
- Task resumption
In serial code, that overhead is pure cost with no benefit—hence the 39x difference.
The Decision Framework
| Situation | Choice | Why |
|---|---|---|
| New concurrent code | Actor | 2.5-3x faster + compile-time safety |
| Heavy work in critical section | Actor | 7.5x faster, less thread contention |
| Obj-C interop | NSLock | Actors don't bridge |
| iOS 16+ lock-based | OSAllocatedUnfairLock | Modern API, same performance |
| Hot serial code path | Locks | 39x faster, no async overhead |
| Mixed/unknown workload | Actor | Safety wins, good enough perf |
The Actor Pattern
When you choose actors, the code is clean:
actor ContainerActor {
private var values: [Int] = []
func write(_ value: Int) {
values.append(value)
}
func read() -> Int {
values.last ?? 0
}
}
No locks to manage. No deadlock risk. No forgotten unlocks. The compiler enforces isolation.
When to Reach for Locks
Locks still have their place:
- Hot serial paths - Profiling shows async overhead matters (39x difference!)
- Obj-C interoperability - Actors don't bridge to Objective-C
- Synchronous requirements - When you can't use async/await
- Existing lock-based code - Don't refactor without measuring
Interview Tip
"I default to actors for new Swift code—they're safe by design and 2.5-3x faster under concurrency. But I know locks are 39x faster in serial code, so I profile before making that tradeoff. The heavy work scenario is interesting—actors are 7.5x faster there because long lock hold times cause massive thread contention."