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
★★
  • Concise.
  • Forces async API on all calls.
  • May introduce actor re-entrancy and thread hops.
  • Compiler enforced isolation. Programmer mistakes are still possible.
  • Complex API.
★☆
Some overhead from async dispatch, message sending, potential thread hops. Far slower than atomics, but overhead can be amortized for larger tasks.
★★
  • Task suspension, threads don’t block.
★★★
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
★★
  • Concise.
  • Limited to specific operations on primitive types.
★★
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.
★☆☆☆
  • Crashes if unlocking from wrong thread.
  • Variant for recursive locking (NSRecursiveLock).
  • Manual lock and unlock. May deadlock with incorrect lock ordering.
  • Auto-unlocks if owning thread terminates
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:

  • Thread explosion from creating too many queues.
  • Deadlocks from queue cycles with sync calls.
  • Blocking/performance issues from sync calls.
Cancellation, groups, barriers
OSAllocatedUnfairLock
☆☆
  • Manual allocation and deinit.
  • Needs to lock and unlock in the same thread.
  • Crashes on misuse.
  • No recursive locking.
★★
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:
  • Lock is not duplicated if owning instance is copied (like some other low level locks).
  • Crashes if unlocking from wrong thread.
  • Crashes if locked twice.
  • withLock {} API prevents forgotten unlocks.
  • Does NOT auto-unlock if owning thread terminates
Non-recursive only
NSOperation
★★★
Best ease of use –if you need those features
  • High-level API with extensive features.
  • Built-in support for dependencies and cancellation.
  • Simple to subclass for custom operations.
  • KVO-compliant for operation state monitoring.
☆☆
Similar overhead to Serial Queue plus additional overhead for dependency management and KVO observation.
★☆
  • Operation-level granularity.
  • Smart dependency management reduces unnecessary blocking.
  • Better than Serial Queue but still has queue overhead.
★★★
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:

  • Operation state management.
  • Dependency system prevents race conditions.
  • Automatic handling of cancellation states.
  • Thread-safe property setters and getters.
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:
// os_unfair_lock is for C APIs with manual memory alignment.
// In Swift it may cause memory misaligment and crashes.
class MyClass {
    private var lock = os_unfair_lock()
}

// Always use os_unfair_lock_s instead.
class MyClass {
    private var lock: os_unfair_lock_s = .init()
}

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.

// Unsafe: value semantics, the lock will be copied
struct Container {
    var lock = os_unfair_lock_s()
}

// Safe: stable memory location
final class LockContainer {
    let lock = os_unfair_lock_s()
}

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.

let lock = NSLock()
try await withThrowingTaskGroup(of: Void.self) { group in
    lock.lock()
    
    // safety unlock
    defer { lock.unlock() }
  
    // throws if task is cancelled
    try Task.checkCancellation()
}

Actor

actor Container {
    var value: Int
    
    func setValue(_ newValue: Int) {
        value = newValue
    }
}

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.

import Atomics

final class Item {
    private let _value: ManagedAtomic<Int>
    
    var value: Int {
        get { _value.load(ordering: .acquiring) }
        set { _value.store(newValue, ordering: .releasing) }
    }
    
    init(value: Int = 0) {
        _value = ManagedAtomic(value)
    }
}

NSLock

import Foundation

final class Item {
    private let lock = NSLock()
    private var _value: Int
    
    var value: Int {
        get {
            lock.lock()
            defer { lock.unlock() }
            return _value
        }
        set {
            lock.lock()
            _value = newValue
            lock.unlock()
        }
    }
    
    init(value: Int = 0) {
        self._value = value
    }
}

Consider using a withLock pattern instead:

extension NSLock {
    func withLock<T>(_ work: () throws -> T) rethrows -> T {
        lock()
        defer { unlock() }
        return try work()
    }
}

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

// Not Sendable
final class Item {
    var value: Int
}

// Sendable
final class Item: Sendable {
    private var _value: Int
    private let queue = DispatchQueue(label: "Item")
    var value: Int {
        get { Self.queue.sync { _value } }
        set { Self.queue.sync { _value = newValue } }
    }
}

If you wanted to execute async operations inside the protected section (not the case here), you would bridge GCD with withCheckedContinuation:

final class Item {
    private let queue = DispatchQueue(label: "com.item.value.queue")
    private var _value: Int
    
    var value: Int {
        get async {
            await withCheckedContinuation { continuation in
                queue.async {
                    continuation.resume(returning: self._value)
                }
            }
        }
        set {
            queue.async {
                self._value = newValue
            }
        }
    }
    
    init(value: Int = 0) {
        self._value = value
    }
}

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

import os

final class Item: Sendable {
    private let lock: OSAllocatedUnfairLock<Int>
    
    init(value: Int) {
        self.lock = OSAllocatedUnfairLock(initialState: value)
    }
    
    var value: Int {
        get {
            lock.withLock { $0 }
        }
        set {
            lock.withLock { state in
                state = newValue
            }
        }
    }
}

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.

import os

public final class Item: Equatable, Sendable {
    private struct State: @unchecked Sendable {
        var content: String
        var language: SupportedLanguage
    }

    private let lock: OSAllocatedUnfairLock<State>

    var content: String {
        get {
            lock.withLock { $0.content }
        }
        set {
            lock.withLock { state in
                state.content = newValue
            }
        }
    }
    var language: SupportedLanguage {
        get {
            lock.withLock { $0.language }
        }
        set {
            lock.withLock { state in
                state.language = newValue
            }
        }
    }

    public init(language: SupportedLanguage, content: String) {
        lock = OSAllocatedUnfairLock(
            initialState: State(
                content: content, 
                language: language
            )
        )
    }

    public static func == (lhs: Item, rhs: Item) -> Bool {
        lhs.content == rhs.content &&
        lhs.language == rhs.language
    }
}