Peculiarities of Mutex

May 4, 2025

Prior to iOS 16 and macOS 13, there were two primary ways to incorporate locks into Swift apps: using low-level primitives like pthread_mutex_t or os_unfair_lock, which come with their own pitfalls, or relying on Foundation’s higher-level NSLocking types such as NSLock or NSRecursiveLock. In this article, we’ll focus on the latter approach and explore why adopting newer APIs like os.OSAllocatedUnfairLock and Synchronization.Mutex becomes important when migrating to Swift 6 with strict concurrency checking enabled.

Good, Old Generics

Let’s start with the key difference between Foundation’s older locking APIs and the newer ones: NSLock isn’t designed to store the protected state. It’s simply a standalone object that you reference separately, alongside the state you want to protect. This has significant consequences - you, the programmer, are responsible for ensuring all interactions with the protected state are correctly wrapped in a withLock call:

open class NonSendable {
    func mutate() { ... }
}

final class Store {
    private let lock = NSLock()
    private var state = NonSendable()

    func mutateState() {
        lock.withLock {
            state.mutate()
        }
    }

    func mutateState_unprotected() {
        // The compiler can’t prevent us from accessing `state` outside the lock’s scope,
        // since there's no association between the `state` property and the `lock`.
        state.mutate()
    }
}

In contrast, both OSAllocatedUnfairLock and Mutex are generic, meaning they can hold the protected state internally. As a result, you no longer need to store the state separately or worry about accidental unprotected access - the state is only accessible within the withLock scope:

final class Store {
    private let lock = Mutex(NonSendable())

    func mutateState() {
        lock.withLock { state in
            state.mutate()
        }
    }

    func mutateState_unprotected() {
        // Impossible - you can't access the state instance directly!
    }
}

So far, so good. Switching from NSLock to OSAllocatedUnfairLock or Mutex brings clear advantages - and it looks pretty straightforward, right? Well, it’s a bit more complicated than that - but for very good reasons.

Compile, Please!

The previous example compiled just fine. Now let’s try switching the implementation to use OSAllocatedUnfairLock:

// Error: Type 'NonSendable' does not conform to the 'Sendable' protocol.
private let lock = OSAllocatedUnfairLock(initialState: NonSendable())

The first time I saw this error, I was puzzled - isn’t the whole point of locks to allow safe access to non-sendable state across isolation domains? Why does the value stored by this lock type need to be Sendable?

Up to this point, we’ve wanted to add thread-safety to our code without worrying much about how it’s achieved. But this error reveals a clear difference in behavior, so let’s dig deeper. We’ll start with a basic breakdown:

Type

Library

Availability

System Primitive

OSAllocatedUnfairLock

os

iOS 16.0+, macOS 13.0+ (Apple platforms only)

os_unfair_lock

Mutex

Synchronization (Swift Standard Library)

iOS 18.0+, macOS 15.0+, Linux, Windows

os_unfair_lock (on Apple platforms)

This alone doesn’t explain much - both types use the same underlying system primitive. However, an important change came along iOS 18 and macOS 15: the introduction of Swift 6 and its region-based isolation. Let’s look at the (distilled) public interfaces of these two lock types to understand the key differences:

struct OSAllocatedUnfairLock<State> {
    init(uncheckedState: State)
    func withLock<R: Sendable>(_ body: @Sendable (inout State) -> R) -> R
}

extension OSAllocatedUnfairLock where State: Sendable {
    init(initialState: State)
}
struct Mutex<Value> {
    init(_ initialValue: consuming sending Value)
    func withLock<R>(_ body: (inout sending Value) -> sending R) -> sending R
}

Now we’re getting somewhere! Many Swift developers are already familiar with the differences between Sendable and the newer sending keyword - especially in the context of closures passed to Task initializers. Region-based isolation brought major improvements in ergonomics when dealing with non-sendable types in such contexts, and Mutex inherits similar benefits through the use of inout sending.

By contrast, OSAllocatedUnfairLock was introduced before region-based isolation and was therefore designed with the older, stricter Sendable requirement in mind. As a result, OSAllocatedUnfairLock remains a valuable tool for developers targeting iOS 16–18 or macOS 13–15, while Mutex, as part of the Swift Standard Library, is better suited for latest platforms.

But this raises an important question: why do the values we store inside these locks need to be Sendable or sending in the first place?

Lockpicking Developer

To understand the reasoning behind requiring Sendable/sending values, let’s return to our example using NSLock and consider some innocent-looking code:

final class Store {
    private let lock = NSLock()
    private var state = NonSendable()
  
    // ...
  
    func getCurrentState() -> NonSendable {
        lock.withLock {
            state
        }
    }
}

At first glance, this looks safe - access to state is locked, right? But look again: the NonSendable instance is being returned from within the locked scope. This means the caller now holds a reference to the protected value outside of the lock, completely bypassing synchronization. That opens the door to race conditions, which may not surface until a crash occurs - in development or production:

let state = store.getCurrentState()
DispatchQueue.global().async {
    // Swift 6: Warning: capture of 'state' with non-sendable type 'State' in a '@Sendable' closure.
    // Swift 5: No warnings.
    state.mutate()
}
// The same `NonSendable` instance is mutated concurrently!
store.mutateState()

A similar problem can arise when passing an externally referenced value into the object responsible for synchronization:

extension Store {
    func setState(_ newState: NonSendable) {
        lock.withLock {
            self.state = newState
        }
    }
}
let state = NonSendable()
store.setState(state)
DispatchQueue.global().async {
    // Swift 6: Warning: capture of 'state' with non-sendable type 'NonSendable' in a '@Sendable' closure.
    // Swift 5: No warnings.
    state.mutate()
}
// The same `NonSendable` instance is mutated concurrently!
store.mutateState()

In both examples, the root problem is that the lock only guards access to the state property of the Store instance, but it's completely unaware of the usage of underlying NonSendable instance in other parts of the app.

Trying Out OSAllocatedUnfairLock

So, does OSAllocatedUnfairLock actually protect us from introducing the race conditions we just explored? Let’s test both scenarios:

final class Store {
    private let lock = OSAllocatedUnfairLock(uncheckedState: NonSendable())
  
    // ...
  
    func getCurrentState() -> NonSendable {
        lock.withLock { state in
            // Error: Type 'NonSendable' does not conform to the 'Sendable' protocol.
            state
        }
    }

    func setState(_ newState: NonSendable) {
        lock.withLock { state in
            // Error: Capture of 'newState' with non-sendable type 'NonSendable' in a '@Sendable' closure
            state = newState
        }
    }
}

These are good - if slightly overzealous - diagnostics. You may have noticed that we had to use init(uncheckedState:) to initialize the lock with a non-sendable type. This is required for OSAllocatedUnfairLock, and in this case it’s perfectly safe: the NonSendable instance is created inline, and there are no external references to it.

But the compiler doesn’t know that. Wouldn’t it be great if we could somehow tell the compiler about this? With region-based isolation and sending keyword, we can!

Embracing Mutex

This is why our original example using Mutex compiled successfully - even with a NonSendable instance passed directly to it. It was safe, and the compiler was able to verify that. Let's test how Mutex would handle the same problematic patterns we saw earlier:

final class Store {
    private let lock = Mutex(NonSendable())

    // ...

    func getCurrentState() -> NonSendable {
        lock.withLock { state in
            // Expected error: 'inout sending' parameter 'state' cannot be task-isolated at end of function.
            // Note: This currently compiles with Swift 6.1 due to the bug in the compiler.
            // https://github.com/swiftlang/swift/issues/81274
            return state
        }
    }

    func setState(_ newState: NonSendable) {
        lock.withLock { state in
            // Error: 'inout sending' parameter 'state' cannot be task-isolated at end of function.
            state = newState
        }
    }
}

Again, the compiler catches our mistakes and prevents unsafe behavior. But unlike Sendable conformance, sending has more relaxed requirements - so is there a way to make this implementation work while keeping the safety guarantees?

Let’s start with getCurrentState method. While we don’t need to change its signature, we do need to adjust its behavior:

func takeCurrentState() -> /* sending */ NonSendable {
    lock.withLock { state in
        let currentState = /* consume */ state
        state = NonSendable()
        return currentState
    }
}

Here, the solution is to dereference/consume the protected state and replace it with a new one within the withLock closure's scope. The new method name, takeCurrentState, reflects this change. Now, the returned instance can safely be transferred across isolation domains and mutated by the caller.

But what about setState method? If you’re thinking that marking the parameter as sending might help - you’re on the right track, but it’s not enough:

func setState(_ newState: sending NonSendable) {
    lock.withLock { state in
        // Error: 'inout sending' parameter 'state' cannot be task-isolated at end of function.
        state = newState
    }
}

This took me a while to understand. A Swift Forums discussion was crucial in helping me see what’s really happening here - and the answer is surprisingly deep: we’ve wandered into an edge case that’s currently inexpressible in Swift language!

The issue isn’t with the function parameter - it’s with the withLock method itself, and more generally with any function that accepts a closure parameter that is guaranteed to be called no more than once. I don’t recall this ever coming up as a problem before, but with region-based isolation and sending values, it becomes significant. Here’s a minimal example that has nothing to do with Mutex, but illustrates the same underlying issue:

func task<T>(_ value: sending T) {
    Task {
        print(value)
    }
}

func oneTask<T>(_ transfer: () -> sending T) {
    task(transfer())
}

func twoTasks<T>(_ transfer: () -> sending T) {
    task(transfer())
    task(transfer())
}

let state = NonSendable()
task(state)

let state1 = NonSendable()
// Error: Returning task-isolated 'state1' as a 'sending' result risks causing data races.
oneTask { state1 }

let state2 = NonSendable()
// Error: Returning task-isolated 'state2' as a 'sending' result risks causing data races.
twoTasks { state2 }

Even though oneTask function only calls the closure once, just like task, the compiler must assume the closure could be called multiple times - as in twoTasks. Since a NonSendable instance cannot be safely referenced from multiple isolation domains, this creates a potential race condition, which the compiler prevents on the caller’s side.

This same reasoning applies to the inout sending parameter in the withLock method - except instead of returning a sending value, we’re assigning one to the inout variable.

SingleUseTransfer To The Rescue

Is it even possible to safely transfer an existing non-sendable value into the state protected by Mutex? It is - and I’ve included a solution in my Principle package, as part of the PrincipleConcurrency module. It’s called SingleUseTransfer.

This utility doesn’t bypass any concurrency checks. However, since we’re working near the edge of what the Swift language can currently express, it comes with a tradeoff that some may find uncomfortable: implicit force unwrapping. SingleUseTransfer, as the name implies, is designed to be used at most once - and it’s the developer’s job to ensure that. That said, when used responsibly, it allows code like the following to compile cleanly and safely:

import PrincipleConcurrency

extension Store {
    func setState(_ newState: sending NonSendable) {
        var transfer = SingleUseTransfer(newState)
        lock.withLock { state in
            state = transfer.finalize()
        }
    }
}

If you’ve run into issues using sending values with closures, I encourage you to give this approach a look - it enables powerful patterns that the compiler can still verify as concurrency-safe. Personally, I find it much easier to reason about force unwrapping than about concurrency issues masked by unsafe annotations like @unchecked Sendable or nonisolated(unsafe).

Don't Cheat

If you’re migrating to Swift 6 and come across usage of NSLock, pthread_mutex_t, or os_unfair_lock in your code, I understand why it might be tempting to simply mark the enclosing type as @unchecked Sendable and move on without adopting any new APIs. After all, you (or your users) may have verified that the code is safe.

Personally, I believe that the current inconveniences of using Mutex are far outweighed by the long-term benefits - both for the safety of existing code and for the flexibility of future modifications to these critical parts of your app. And even if you can’t adopt Mutex just yet due to platform version constraints, it’s still worth experimenting with. Doing so deepened my understanding of Swift Concurrency and gave me a much greater appreciation for the incredible work that makes such complex analysis possible at compile time.

Apple, the Apple logo, Swift, the Swift logo, Xcode, and related marks are trademarks of Apple Inc., registered in the U.S. and other countries. Any references to these trademarks are for informational and illustrative purposes only, to showcase projects built using Apple development tools and platforms.

Copyright © 2025 Kamil Strzelecki. All Rights Reserved.