Swift Synchronization Mechanisms
An overview of synchronization mechanisms in Apple platforms.
What synchronization to use
This SVG renders fine in Chrome.
Comparison
Method | Ease of Use | Performance | Contention | Priority Inversion | Thread Safety | Special Features |
---|---|---|---|---|---|---|
Actor |
|
Some overhead from async dispatch, message sending, potential thread hops.
Far slower than atomics, but overhead can be amortized for larger tasks.
|
|
The Swift runtime has sophisticated handling: when a high-priority task waits on an actor running a low-priority task, the low-priority task is temporarily elevated to match the waiting task's priority.
|
Some thread safety is enforced by the compiler but programmer errors are still possible. For instance, deadlocks due to actor re-entrancy.
|
Async only, isolated state |
Atomics |
|
Fastest for single operations.
|
Minimal waiting, just CPU-level synchronization.
|
N/A |
Built-in because each individual operation is atomic.
|
Limited to simple types |
NSLock |
Moderate overhead from pthread mutex operations, thread parking/unparking.
|
Immediate blocking.
|
Basic priority inheritance through pthread mutex: it only handles direct priority inheritance (one mutex, two threads). Therefore not as sophisticated as actors, or unfair lock.
|
|
Recursive and distributed variants | |
Serial Queue |
Highest overhead due to GCD queue machinery, block copying, and context switches.
|
Forces all work to be serialized, similar blocking to NSLock.
|
GCD provides some priority inheritance but can still suffer from priority inversions, especially with queue hierarchies. The exact behavior depends on QoS (Quality of Service) levels and queue attributes.
|
Automatic: instead manual locking and unlocking tasks are submitted to a queue and executed in FIFO order. Problems are still possible:
|
Cancellation, groups, barriers | |
OSAllocatedUnfairLock |
|
Minimal overhead, just CPU lock instructions, spins briefly before parking thread.
|
Brief spinning then blocking.
|
Good priority inheritance but less sophisticated than Actors. Specifically designed with priority inheritance –the thread holding the lock temporarily inherits the highest priority of any waiting threads. The "unfair" part means it doesn't guarantee FIFO ordering, allowing high-priority threads to jump the queue.
|
Requires careful use:
|
Non-recursive only |
NSOperation |
Best ease of use –if you need those features
|
Similar overhead to Serial Queue plus additional overhead for dependency management and KVO observation.
|
|
Sophisticated priority handling through operation priorities and QoS classes. Dependencies help prevent priority inversions by maintaining execution order based on priority.
|
Strong safety guarantees through:
|
Dependencies, cancellation, progress tracking, completion blocks |
Deprecated:
OSSpinLock
: use OSAllocatedUnfairLock instead.OSAtomic*
: use Swift Atomics package or C++/C11 atomics instead.os_unfair_lock_s
: use it through OSAllocatedUnfairLock to avoid misuse.
I didn’t include Swift 6 Mutex introduced in SE-0433 because it is a basic version of the OSAllocatedUnfairLock API. It uses os_unfair_lock underneath. The difference is that it runs on all platforms where Swift is available, it uses modern features like Sendable conformance and transferring
.
os_unfair_lock is easy to misuse
First it is badly documented. The official docs are a placeholder. The open source headers have more information, but they don’t tell the following problems.
You gotta be careful not to use the wrong API:Given that os_unfair_lock
is a typedef of os_unfair_lock_s
you may think it is exactly the same thing, but os_unfair_lock
is treated as an opaque C type in Swift. This means that it can’t be directly inspected or safely initialized by Swift. Thus, always use os_unfair_lock_s
because it is exposed as a Swift struct that Swift can understand.
Another problem is storing the lock in movable memory. Once locked, the state of the lock is expected to be at a specific memory address, stored as a 32 bit value so it can be modified atomically. This may be surprising, but it is a low level lock. Again, safe to use when contained inside an object, but requires careful consideration when used across an application.
Another problem is releasing the owner of the lock while locked. And yet another is that using & (inout operator) triggers willset didSet operators and violate the memory safety.
Glossary
Here are some definitions for terms I used in the tooltips above.
- Actor re-entrancy
- a second call to an ongoing operation that was previously suspended because it made an internal async call.
- Contention
- A condition where multiple threads attempt to access and modify the same resource simultaneously, leading to competition and potential waiting or blocking.
- Priority inversion
- A scheduling defect that happens when a high-priority task is delayed due to a lower-priority task holding a shared resource. For instance, A (high priority) needs a resource held by C (low priority), but B (medium priority) takes precendence over A, effectively making A wait for B despite having higher priority.
- Recursive locking
- a feature of a lock that lets the same thread re-acquire the same lock.
- Spin
- actively waiting in a tight loop, consuming CPU cycles, rather than yielding the thread. Used for very short waits since it avoids the overhead of thread context switching.
- Thread hop
- A thread hop is a change of execution thread of an actor when a suspension point resumes. It is a common occurrence, and it impacts performance due to context switching and CPU cache coherency.
- Thread parking
- Suspending a thread's execution and removing it from the CPU scheduling queue until it's awakened by another thread. More efficient than spinning for longer waits but has overhead from context switching.
Example implementations
In the following examples synchronization is private and encapsulated inside individual objects. Therefore all examples are 100% compatible to use with Structured Concurrency.
Private synchronization won’t:
- Hold locks across task boundaries.
- Create dependencies between tasks.
- Interfere with cancellation of Tasks.
- Create deadlock opportunities with other synchronization mechanisms.
It’s only when synchronization mechanisms become visible across isolation contexts they can break the "structured" part of Structured Concurrency by creating hidden dependencies between tasks. And also, be aware of the features of each lock, you must remember to clean up when a task is cancelled.
A lock example that unlocks properly when the task is cancelled.
Actor
This is the most concise implementation presented here, at the cost of everything becoming async.
Atomics
This code uses the atomics package. The Synchronization framework from Apple offers some other atomic operations.
NSLock
Consider using a withLock pattern instead:
To reacquire locks you would use NSRecursiveLock instead. There is also a NSDistributedLock variant for inter-process locks like app extensions that share resources with their host app.
Serial Queue
If you wanted to execute async operations inside the protected section (not the case here), you would bridge GCD with withCheckedContinuation
:
Note that the setter is fire and forget. If you need to wait before the value is set, then write set async
instead set
.
OSAllocatedUnfairLock
OSAllocatedUnfairLock is already @unchecked Sendable.
If you want to protect a second property is a good idea to use a single lock for both. Given that the lock wraps the protected value you will have to create an internal struct to hold both. Example.
Example of protecting two properties with one lock.