Concurrency in Swift used to be a wild west of race conditions and subtle bugs. Swift 6 cracks down with stricter rules. I found it interesting to do a review of how old and new synchronization tools get along in this new scenario.

Understanding Concurrency

What is Concurrency?

Concurrency involves multiple tasks running simultaneously, often sharing mutable state. The challenge is to keep this shared state consistent and correct, even when execution order is unpredictable.

What Makes Code Correct?

Correct code is code that conforms to its specification. A good specification defines:

  • Invariants: Conditions that must always hold true, constraining the state.
  • Preconditions and Postconditions: Conditions that are true before and after operations, describing their effects.

Note we are defining correctness in terms of state. State is all your stored values at any given time. To manage this, we isolate code into independent units and examine their behavior through invariants (what must always hold?) and pre/postconditions (what’s true before and after an operation?).

The human short-term memory can hold up to seven disconnected elements at once—think of a phone number with arbitrary digits, which already pushes that limit. In software, even four booleans yield 2^4 = 16 possible states, far exceeding our ability to visualize. This complexity is why we rely on structured approaches to maintain correctness.

What is Thread-safe Code?

Thread-safe code is code that remains correct when executed by multiple threads. That is:

  • No sequence of operations can violate the specification.
  • Invariants and conditions will hold during multithread execution without requiring additional synchronization by the client.

For instance, saving my changes to a file shouldn’t overlap saving operations even if I spam the save button. I trust that multiple concurrent operations will be debounce/serialized/avoided in a fool-proof manner by the application/library developer. Such code is deemed thread-safe.

How to Achieve Thread Safety?

Thread-safety requires that the specification holds true during multithread execution, which requires only one thing: regulate the access to mutable shared state.

There are three ways to do it:

  • Prevent Access: Restrict state exposure entirely.
  • Make State Immutable: Eliminate mutation to avoid conflicts.
  • Synchronize Access: Control how threads interact with the state.

The first two are simple. The third one requires preventing a variety of thread-safety problems.

Common Thread-Safety Problems

**Liveness Problems

  • Deadlock: Threads waiting indefinitely for each other’s resources.
  • Livelock: Threads actively responding to one another without making progress.
  • Starvation: A thread never gets the resources it needs to proceed.
  • Priority Inversion: Lower-priority threads hold resources needed by higher-priority ones, delaying their execution.

State Consistency Issues

  • Race Conditions: Outcomes depend on the unpredictable timing of thread execution.
  • Atomicity Violations: Compound operations are interrupted, leaving state in an inconsistent intermediate state.
  • Safe Publication & Memory Visibility: Without proper synchronization, threads might see stale or incomplete data.

To solve these problems we use concurrency tools.

What is a Concurrency Tool?

A concurrency tool is any mechanism that serializes access to mutable shared state, ensuring invariants and correctness hold across concurrent threads.

For instance, a boolean flag (e.g., isFileInUse) isn’t a concurrency tool. It might seem to work—reading a flag is fast—but occasionally, two threads could read it as available simultaneously, write concurrently, and corrupt the file. Worse, such bugs are hard to reproduce. You’ve just swapped the file resource for a flag resource without solving the issue.

In contrast, a lock is a true concurrency tool. It acts as a foolproof gate, serializing access to the resource and preventing such conflicts.

Synchronization Primitives in Swift

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.
  • Only classes can be @Observable.
  • 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
Mutex (Swift 6)
★★☆
  • Clean, modern API with closure-based locking.
  • Works on all Swift platforms.
  • No recursive locking.
★★
High performance, similar to OSAllocatedUnfairLock on Apple platforms.
★☆
Brief spinning then blocking.
★★☆
Good priority inheritance on platforms that support it.
★★☆☆
  • Safe .sync {} API prevents forgotten unlocks.
  • Sendable conformance built-in.
  • No support for recursive locking.
Cross-platform, transferring ownership
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
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
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
TokenBucket
★★☆
  • Clean async/await API.
  • Requires understanding task suspension.
  • More complex to implement correctly.
★☆
Moderate overhead from task suspension and continuations, but no thread blocking.
★★★
Excellent under high contention, suspends tasks rather than blocking threads.
★★★
Benefits from Swift's task priority system, inheriting the priority handling from actors.
★★★
  • Actor-based isolation provides strong safety guarantees.
  • Works well with structured concurrency.
  • No risk of forgotten unlocks.
Task suspension, concurrency limiting
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

The Swift 6 Mutex introduced in SE-0433 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.

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.

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 memory safety.

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
    }
}

Actors implicitly conform to Sendable so this is the most concise implementation presented here, at the cost of all clients having to call asynchronously.

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)
    }
}

Mutex

Swift 6 introduced the Mutex type in SE-0433. It provides a cross-platform synchronization primitive that works across all Swift platforms.

import Synchronization

final class Item: Sendable {
    private var _value: Int
    private let mutex = Mutex()
    
    var value: Int {
        get {
            mutex.sync { _value }
        }
        set {
            mutex.sync { _value = newValue }
        }
    }
    
    init(value: Int = 0) {
        self._value = value
    }
}

The Mutex type is already Sendable and provides a clean API with the sync method that ensures the mutex is properly unlocked even if exceptions are thrown within the closure. On Apple platforms, it uses os_unfair_lock underneath.

For cross-platform Swift code, Mutex is the recommended synchronization primitive since it uses Swift’s type system and maintains consistent behavior across operating systems.

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.

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
    }
}

Serial Queue

// not thread-safe 
final class Item {
    var value: Int
}

// thread-safe
final class Item: @unchecked 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 } }
    }
    init(){ _value = 0 }
}

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.

TokenBucket

The TokenBucket pattern allows limiting concurrency through task suspension rather than thread blocking. This is compatible with Swift 6 and offers protection against reentrancy. At least, unless the work closure passed to withToken calls back into the same actor through an async boundary, but that’s a matter of separating the inner mechanisms from the work itself.

TokenBucket limits concurrency by suspending tasks instead of blocking threads. If the code seems alien here is the gist of it:

  • if tokens == 0 call try await waitForToken(). This adds the continuation to the queue and suspends, waiting for the resume call to the continuation.
  • when work from another task finishes, it calls releaseToken(). This brings back the continutation from the queue and resumes it.
  • the next line after the resume is availableTokens -= 1 then it runs the actual work and the cycle repeats
// A simplified version of Swift Package Manager's TokenBucket
import Foundation

actor TokenBucket {
    private var availableTokens: Int
    private var waitingTasks: [(CheckedContinuation<Void, Error>)] = []
    
    init(tokens: Int) {
        self.availableTokens = tokens
    }
    
    func withToken<R>(_ work: () async throws -> R) async throws -> R {
        if availableTokens == 0 {
            try await waitForToken()
        }
        
        availableTokens -= 1
        
        do {
            let result = try await work()
            releaseToken()
            return result
        } catch {
            releaseToken()
            throw error
        }
    }
    
    private func waitForToken() async throws -> Void {
        try await withCheckedThrowingContinuation { continuation in
            waitingTasks.append(continuation)
        }
    }
    
    private func releaseToken() {
        if !waitingTasks.isEmpty {
            let continuation = waitingTasks.removeFirst()
            continuation.resume()
        } else {
            availableTokens += 1
        }
    }
}

// MARK: - Testing

struct Foo {}

actor ConcurrencyLimitedProcessor {
    // Limit to 2 concurrent operations
    private let tokenBucket = TokenBucket(tokens: 2)
    
    func process(data: Data) async throws -> Foo {
        try await tokenBucket.withToken {
            // Only 5 concurrent processing operations will be allowed
            // Others will suspend until a token becomes available
            try await performExpensiveProcessing(data)
        }
    }
    
    private func performExpensiveProcessing(_ data: Data) async throws -> Foo {
        try await Task.sleep(for: .milliseconds(50)) 
        return Foo()
    }
}


func launchConcurrentTasks() async throws {
    let processor = ConcurrencyLimitedProcessor()
    
    try await withThrowingTaskGroup(of: Foo.self) { group in
        for i in 1...10 {
            group.addTask {
                print("Starting task \(i)")
                return try await processor.process(data: "Task \(i)"
                    .data(using: .utf8)!)
            }
        }
        
        for try await _ /*result*/ in group {
            print("Task completed")
        }
    }
    
    print("All tasks completed")
}

Task { try await launchConcurrentTasks() }

// keep the playground alive for a sec
RunLoop.current.run(mode: .default, before: Date.init(timeIntervalSinceNow: 1))

TokenBucket suspends tasks rather than blocking threads, which is more efficient for longer-running operations (e.g. files, network) or under high contention.

Recommendations

With Swift 6’s stricter concurrency checking, choosing the right synchronization mechanism becomes even more important. Here are specific recommendations based on different scenarios:

Pick your Tool

Swift’s concurrency model now provides two fundamentally different approaches to synchronization:

  1. Thread-blocking mechanisms (traditional locks)
    • Block threads when waiting for access
    • Generally simpler for synchronous code
    • Better performance for short-duration critical sections
    • Examples: Mutex, OSAllocatedUnfairLock, NSLock
  2. Task-suspension mechanisms (actor-based, TokenBucket)
    • Suspend tasks rather than blocking threads
    • Better aligned with Swift’s structured concurrency model
    • More efficient for high-contention or long-duration operations
    • Examples: actors, TokenBucket, AsyncSemaphore

Scenarios

Scenario Recommended Approach Notes
Cross-platform code Mutex Works consistently across all Swift platforms
Simple state protection OSAllocatedUnfairLock Best performance on Apple platforms
UI state @MainActor Ensures all access happens on the main thread
High-contention scenarios Actor or TokenBucket Prevents thread explosion under load
Read-heavy workloads DispatchQueue with barriers Allows concurrent reads with exclusive writes
API design Actor-based Best alignment with Swift’s structured concurrency
Legacy code integration @unchecked Sendable with locks Minimizes required changes

Lock Types

Lock Type Performance Use Case Swift 6 Compatibility
OSAllocatedUnfairLock Fastest built-in lock Simple state protection Excellent
NSLock Moderate General purpose Good
DispatchQueue+barrier Higher overhead Read-heavy workloads Good
NSRecursiveLock Higher overhead Recursive code Good
pthread_rwlock_t Moderate Read-heavy workloads Low-level, requires care

Performance Considerations

For short operations (microseconds, simple property access): Thread-blocking mechanisms have lower overhead, often just a few CPU instructions. Choose OSAllocatedUnfairLock or Mutex. Task-suspension, on the other hand, isn’t free: it involves allocating continuations, scheduling tasks, and potentially hopping threads, which adds runtime overhead.

For longer operations (milliseconds, I/O, network): Task-suspension keeps the app responsive by avoiding thread explosion. This is especially true under high contention, where many threads are competing for the same resource.

For frequent access OSAllocatedUnfairLock or Mutex provide the best performance. But for mostly-read access, where writes are rare, a DispatchQueue with barriers balances safety and performance. The difference is that the reader-writer pattern in GCD is optimized at a low level, avoiding the overhead of repeatedly acquiring and releasing locks for read operations. In contrast, with Mutex or OSAllocatedUnfairLock, each read operation would need to acquire and release the lock sequentially, preventing concurrent reads and creating a bottleneck even though the operations don’t conflict with each other. This makes old DispatchQueue better for caching, configuration, or any data structure that is frequently read but infrequently modified.

The takeaway is to match your mechanism to your workload. Quick operations favor locks; longer or high-contention scenarios favor suspension.

Olsen Locks

Swift 6 Migration Guidance

The stricter checking in Swift 6 helps prevent subtle data races, but requires being more deliberate about synchronization choices. When migrating code to Swift 6:

  1. Prefer actors for new code
    • They align with Swift’s concurrency model
    • Provide automatic Sendable checking
    • Handle priority inheritance automatically
  2. Use Mutex for cross-platform synchronization
    • It’s officially part of Swift as of Swift 6
    • Works consistently across all platforms
    • Uses modern features like Sendable conformance
  3. Consider OSAllocatedUnfairLock for Apple-specific high-performance needs
    • Highest performance on Apple platforms
    • Clean modern API with safe usage patterns
  4. Minimize use of @unchecked Sendable
    • If you have to, explicitly document your thread-safety guarantees
  5. Be careful with task suspension points
    • Locks should not be held across task suspension points
    • Use actor isolation or TokenBucket for scenarios requiring suspension

Some Stackoverflow answers begin with “If you have to ask..”. Well, that was decent advice if you needed some. Not that I 100% follow it. You do you and follow your own adventure.

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 situation where multiple threads compete to access and modify the same resource, leading to potential delays or blocking.
Priority inversion
A scheduling issue where a high-priority task is delayed by a lower-priority task holding a shared resource, disrupting expected execution order.
Recursive locking
A lock feature allowing the same thread to re-acquire the lock multiple times without deadlocking.
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.