Hi all, I would like to propose a new type in the Synchronization
module, Mutex
. This type is a synchronous mutual exclusion lock, similar to things like OSAllocatedUnfairLock
, NIOLock
, etc. Please let me know what you think!
Synchronous Mutual Exclusion Lock
- Proposal: SE-NNNN
- Author: Alejandro Alonso
- Review Manager: TBD
- Status: [stdlib] Implement Mutex in Synchronization by Azoy · Pull Request #71383 · apple/swift · GitHub
Introduction
This proposal introduces a mutual exclusion lock, or a mutex, to the standard library. Mutex
will be a new synchronization primitive in the synchronization module.
Motivation
In concurrent programs, protecting shared mutable state is one of the core fundamental problems to ensuring reading and writing data is done in an explainable fashion. Synchronizing access to shared mutable state is not a new problem in Swift. We've introduced many features to help protect mutable data. Actors are a good default go-to solution for protecting mutable state because it isolates the stored data in its own domain. At any given point in time, only one task will be executing "on" the actor, and have exclusive access to it. Multiple tasks cannot access state protected by the actor at the same time, although they may interleave execution at potential suspension points (indicated by await
). In general, the actor approach also leans itself well to code organization, since the actor's state, and operations on this state are logically declared in the same place: inside the actor.
Not all code may be (or want to) able to adopt actors. Reasons for this can be very varied, for example code may have to execute synchronously without any potential for other tasks interleaving with it. Or the async
effect introduced on methods may prevent legacy code which cannot use Swift Concurrency from interacting with the protected state.
Whatever the reason may be, it may not be feasible to use an actor. In such cases, Swift currently is missing standardized tools to offer developers ensure proper synchronization in their concurrent data-structures. Many Swift programs opt to use ad-hoc implementations of a mutal exclusion lock, or a mutex. A mutex is a simple to use synchronization primitive to help protect shared mutable data by ensuring that a single execution context has exclusive access to the related data. The main issue is that there isn't a single standardized implementation for this synchronization primitive resulting in everyone needing to roll their own.
Proposed solution
We propose a new type in the Standard Library Synchronization module: Mutex
. This type will be a wrapper over a platform-specific mutex primitive, along with a user-defined mutable state to protect. Below is an example use of Mutex
protecting some internal data in a class usable simultaneously by many threads:
class FancyManagerOfSorts {
let cache = Mutex<[String: Resource]>([:])
func save(_ resource: Resouce, as key: String) {
cache.withLock {
$0[key] = resource
}
}
}
Use cases for such a synchronized type are common. Another common need is a global cache, such as a dictionary:
let globalCache = Mutex<[MyKey: MyValue]>([:])
Detailed design
Underlying System Mutex Implementation
The Mutex
type proposed is a wrapper around a platform's implementation.
- macOS, iOS, watchOS, tvOS, visionOS:
os_unfair_lock
- Linux:
futex
- Windows:
SRWLOCK
These mutex implementations all have different capabilities and guarantee different levels of fairness. Our proposed Mutex
type does not guarantee fairness, and therefore it's okay to have different behavior from platform to platform. We only guarantee that only one execution context at a time will have access to the critical section, via mutual exclusion.
API Design
Below is the complete API design for the new Mutex
type:
/// A synchronization primitive that protects shared mutable state via
/// mutual exclusion.
///
/// The `Mutex` type offers non-recursive exclusive access to the state
/// it is protecting by blocking threads attempting to acquire the lock.
/// At any one time, only one execution context at a time has access to
/// the value stored within the `Mutex` allowing for exclusive access.
///
/// An example use of `Mutex` in a class used simultaneously by many
/// threads protecting a `Dictionary` value:
///
/// class Manager {
/// let cache = Mutex<[Key: Resource]>([:])
///
/// func saveResouce(_ resource: Resouce, as key: Key) {
/// cache.withLock {
/// $0[key] = resource
/// }
/// }
/// }
///
/// - Warning: Instances of this type are not recursive. Calls
/// to `withLock(_:)` (and related functions) within their
/// closure parameters will have platform-dependent behavior.
/// Some platforms may choose to panic the process, deadlock,
/// or leave this behavior unspecified.
///
public struct Mutex<Value: ~Copyable>: ~Copyable {
/// Initializes a value of this mutex with the given initial state.
///
/// - Parameter initialValue: The initial value to give to the mutex.
public init(_: consuming Value)
/// Attempts to acquire the lock and then calls the given closure if
/// successful.
///
/// If the calling thread was successful in acquiring the lock, the
/// closure will be executed and then immediately after it will
/// release ownership of the lock. If we were unable to acquire the
/// lock, this will return `nil`.
///
/// This method is equivalent to the following sequence of code:
///
/// guard mutex.tryLock() else {
/// return nil
/// }
/// defer {
/// mutex.unlock()
/// }
/// return try body(&value)
///
/// - Warning: Recursive calls to `tryWithLock` within the
/// closure parameter has behavior that is platform dependent.
/// Some platforms may choose to panic the process, deadlock,
/// or leave this behavior unspecified.
///
/// - Parameter body: A closure with a parameter of `Value`
/// that has exclusive access to the value being stored within
/// this mutex. This closure is considered the critical section
/// as it will only be executed if the calling thread acquires
/// the lock.
///
/// - Returns: The return value, if any, of the `body` closure parameter
/// or nil if the lock couldn't be acquired.
public borrowing func tryWithLock<Result: ~Copyable & Sendable, E>(
_ body: @Sendable (inout Value) throws(E) -> Result
) throws(E) -> Result?
/// Attempts to acquire the lock and then calls the given closure if
/// successful.
///
/// If the calling thread was successful in acquiring the lock, the
/// closure will be executed and then immediately after it will
/// release ownership of the lock. If we were unable to acquire the
/// lock, this will return `nil`.
///
/// This method is equivalent to the following sequence of code:
///
/// guard mutex.tryLock() else {
/// return nil
/// }
/// defer {
/// mutex.unlock()
/// }
/// return try body(&value)
///
/// - Note: This version of `withLock` is unchecked because it does
/// not enforce any sendability guarantees.
///
/// - Warning: Recursive calls to `tryWithLockUnchecked` within the
/// closure parameter has behavior that is platform dependent.
/// Some platforms may choose to panic the process, deadlock,
/// or leave this behavior unspecified.
///
/// - Parameter body: A closure with a parameter of `Value`
/// that has exclusive access to the value being stored within
/// this mutex. This closure is considered the critical section
/// as it will only be executed if the calling thread acquires
/// the lock.
///
/// - Returns: The return value, if any, of the `body` closure parameter
/// or nil if the lock couldn't be acquired.
public borrowing func tryWithLockUnchecked<Result: ~Copyable, E>(
_ body: (inout Value) throws(E) -> Result
) throws(E) -> Result?
/// Calls the given closure after acquring the lock and then releases
/// ownership.
///
/// This method is equivalent to the following sequence of code:
///
/// mutex.lock()
/// defer {
/// mutex.unlock()
/// }
/// return try body(&value)
///
/// - Warning: Recursive calls to `withLock` within the
/// closure parameter has behavior that is platform dependent.
/// Some platforms may choose to panic the process, deadlock,
/// or leave this behavior unspecified.
///
/// - Parameter body: A closure with a parameter of `Value`
/// that has exclusive access to the value being stored within
/// this mutex. This closure is considered the critical section
/// as it will only be executed once the calling thread has
/// acquired the lock.
///
/// - Returns: The return value, if any, of the `body` closure parameter.
public borrowing func withLock<Result: ~Copyable & Sendable, E>(
_ body: @Sendable (inout Value) throws(E) -> Result
) throws(E) -> Result
/// Calls the given closure after acquring the lock and then releases
/// ownership.
///
/// This method is equivalent to the following sequence of code:
///
/// mutex.lock()
/// defer {
/// mutex.unlock()
/// }
/// return try body(&value)
///
/// - Warning: Recursive calls to `withLockUnchecked` within the
/// closure parameter has behavior that is platform dependent.
/// Some platforms may choose to panic the process, deadlock,
/// or leave this behavior unspecified.
///
/// - Note: This version of `withLock` is unchecked because it does
/// not enforce any sendability guarantees.
///
/// - Parameter body: A closure with a parameter of `Value`
/// that has exclusive access to the value being stored within
/// this mutex. This closure is considered the critical section
/// as it will only be executed once the calling thread has
/// acquired the lock.
///
/// - Returns: The return value, if any, of the `body` closure parameter.
public borrowing func withLockUnchecked<U: ~Copyable, E>(
_ body: (inout Value) throws(E) -> U
) throws(E) -> U
}
extension Mutex: Sendable where Value: Sendable {}
Interaction with Existing Language Features
Mutex
will decorated with the @_staticExclusiveOnly
attribute, meaning you will not be able to declare a variable of type Mutex
as var
. These are the same restrictions imposed on the recently accepted Atomic
and AtomicLazyReference
types. Please refer to the Atomics proposal for a more in-depth discussion on what is allowed and not allowed. These restrictions are enabled for Mutex
for all of the same reasons why it was resticted for Atomic
. We do not want to introduce dynamic exclusivity checking when accessing a value of Mutex
as a class stored property for instance.
Interactions with Swift Concurrency
Similar to Atomic
, Mutex
will have a conditional conformance to Sendable
when the underlying value itself is also Sendable
. Consider the following example declaring a global mutex in some top level script:
let lockedPointer = Mutex<UnsafeMutablePointer<Int>>(...)
func something() {
// warning: non-sendable type 'Mutex<UnsafeMutablePointer<Int>>' in
// asynchronous access to main actor-isolated let 'lockedPointer'
// cannot cross actor boundary
// note: consider making generic struct 'Mutex' conform to the 'Sendable'
// protocol
let pointer = lockedPointer.withLock {
$0
}
... bad stuff with pointer
}
Our variable is treated as immutable by the compiler, but the underlying type is not sendable thus this variable is implicitly main actor-isolated. We're attempting to reference a main actor-isolated piece of data in a synchronous global function that is not isolated to any actor (hence the warning). However, this warning is actually appreciated. While the lock does protect the state it's holding onto, it does not protect class references or any underlying memory being pointed to (by pointers). In the example, the global function something
now has access to read/write values from this pointer, but there's nothing synchronizing access to the memory referenced by the pointer. Multiple threads could potentially race with each other trying to read/write data into this memory. The same applies to non-sendable class references, once threads have a hold of the reference, they can potentially race with each other trying to mutate the underlying instance.
As an added measure to prevent this particular issue, the return value of the withLock
API must be Sendable
. This completely invalidates the above code example. We can get around this warning though, if we isolate usages of something()
to the main actor as well:
@MainActor
func something() {
// No more warnings!
lockedPointer.withLock {
...
}
}
Usages of this mutex no longer emit these sendability warnings, but if we alter the example just slightly we see the next issue we need to solve:
class NonSendableReference {
var prop: UnsafeMutablePointer<Int>
}
// Some non-sendable class reference somewhere, perhaps a global.
let nonSendableRef = NonSendableReference(...)
@MainActor
func something() {
lockedPointer.withLock {
// No warnings!
nonSendableRef.prop = $0
}
}
If the withLock
method was not labeled with @Sendable
then this code would emit no warnings. All good right? The compiler hasn't complained to us! Unfortunately, this highlights one of the bigger issues I mentioned earlier in that Mutex
does not protect class references or any underlying memory referenced by pointers. This is still a hole for shared mutable state. We've now given a non-sendable value complete access to the memory pointed to by our value. This non-sendable class does not guarantee synchronization for the data being modified at by the pointer. We need to mark the closure in the withLock
as @Sendable
:
@MainActor
func something() {
lockedPointer.withLock {
// warning: non-sendable type 'NonSendableReference' in asynchronous access
// to main actor-isolated let 'nonSendableRef' cannot cross actor
// boundary
nonSendableRef.prop = $0
}
// or if you tried to be sneaky:
let nonSendableRefCopy = nonSendableRef
lockedPointer.withLock {
// warning: capture of 'nonSendableRefCopy' with non-sendable type
// 'NonSendableReference' in a '@Sendable' closure
nonSendableRefCopy.prop = $0
}
}
By marking the closure as such, we've effectively declared that the mutex is in itself its own isolation domain. We must not let non-sendable values it holds onto be unsafely sent across isolation domains to prevent these holes of shared mutable state.
Differences between mutexes and actors
The mutex type we're proposing is a synchronous lock. This means when other participants want to acquire the lock to access the protected shared data, they will halt execution until they are able to do so. Threads that are waiting to acquire the lock will not be able to make forward progress until their request to acquire the lock has completed. This can lead to thread contention if the acquired thread's critical section is not able to be executed relatively quickly exhausting resources for the rest of the system to continue making forward progress. Synchronous locks are also prone to deadlocks (which Swift's actors cannot currently encounter due to their re-entrant nature) and live-locks which can leave a process in an unrecoverable state. These scenarios can occur when there is a complex hierarchy of different locks that manage to depend on the acquisition of each other.
Actors work very differently. Typical use of an actor doesn't request access to underlying shared data, but rather instruct the actor to perform some operation or service that has exclusive access to that data. An execution context making this request may need to await on the return value of that operation, but with Swift's async
/await
model it can immediately start doing other work allowing it to make forward progress on other tasks. The actor executes requests in a serial fashion in the order they are made. This ensures that the shared mutable state is only accessed by the actor. Deadlocks are not possible with the actor model. Asynchronous code that is dependent on a specific operation and resouce from an actor can be later resumed once the actor has serviced that request. While deadlocking is not possible, there are other problems actors have such as the actoe reentrancy problem where the state of the actor has changed when the executing operation got resumed after a suspension point.
Mutexes and actors are very different synchronization tools that help protect shared mutable state. While they can both achieve synchronization of that data access, they do so in varying ways that may be desirable for some and undesirable for others. The proposed Mutex
is yet another primitive that Swift should expose to help those achieve concurrency safe programs in cases where actors aren't suitable.
Source compatibility
Source compatibility is preserved with the proposed API design as it is all additive as well as being hidden behind an explicit import Synchronization
. Users who have not already imported the Synchronization module will not see this type, so there's no possibility of potential name conflicts with existing Mutex
named types for instance. Of course, the standard library already has the rule that any type names that collide will disfavor the standard library's variant in favor of the user's defined type anyway.
ABI compatibility
The API proposed here is fully addative and does not change or alter any of the existing ABI.
Mutex
as proposed will be a new @frozen
struct which means we cannot change its layout in the future on ABI stable platforms, namely the Darwin family. Because we cannot change the layout, we will most likely not be able to change to a hypothetical new and improved system mutex implementation on those platforms. If said new system mutex were to share the layout of the currently proposed underlying implementation, then we may be able to migrate over to that implementation. Keep in mind that Linux and Windows are non-ABI stable platforms, so we can freely change the underlying implementation if the platform ever supports something better.
Future directions
There are quite a few potential future directions this new type can take as well as new future similar types.
Transferring Parameters
With Region Based Isolation, a future direction in that proposal is the introduction of a transferring
modifier to function parameters. This would allow the Mutex
type to decorate its closure parameter in the withLock
API as transferring
and potentially remove the @Sendable
restriction on the closure altogether. By marking the closure parameter as such, we guarantee that state held within the lock cannot be assigned to some non-sendable captured reference which is the primary motivator for why the closure is marked @Sendable
now to begin with. A future closure based API may look something like the following:
public borrowing func withLock<U: ~Copyable & Sendable, E>(
_: (transferring inout Value) throws(E) -> U
) throws(E) -> U
This would remove a lot of the restrictions that a @Sendable
closure enforces because this closure isn't escaping nor is it being passed to other isolation domains, it's being ran on the same calling execution context. However, again we guarantee that the passed in parameter, who may not be sendable, can't be written to a non-sendable reference within the closure effectively crossing isolation domains.
In addition to marking the closure parameter transferring
, marking the initializer's initial value as well would allow Mutex
to be unconditionally Sendable
:
public init(_ initialValue: transferring consuming Value)
This completes the story of completly marking the value contained within the mutex as being in its own isolation domain. With this, we can say that Mutex
is unconditionally sendable regardless of whether or not the value it contains is itself sendable.
Mutex Guard API
A token based approach for locking and unlocking may also be highly desirable for mutex API. This is similar to C++'s std::lock_guard
or Rust's MutexGuard
:
extension Mutex {
public struct Guard: ~Copyable, ~Escapable {
// Hand waving some syntax to borrow Mutex, or perhaps
// we just store a pointer to it.
let mutex: borrow Mutex<Value>
public var value: Value {...}
deinit {
mutex.unlock()
}
}
}
extension Mutex {
public borrowing func lock() -> borrow(self) Guard {...}
}
func add(_ i: Int, to mutex: Mutex<Int>) {
// This acquires the lock by calling the platform's
// underlying 'lock()' primitive.
let mGuard = mutex.lock()
mGuard.value += 1
// At the end of the scope, mGuard is immediately deinitialized
// and releases the mutex by calling the platform's
// 'unlock()' primitive.
}
The above example shows an API similar to Rust's MutexGuard
which allows access to the protected state in the mutex. C++'s guard on the other hand just performs lock()
and unlock()
for the user (because std::mutex
doesn't protect any state). Of course the immediate issue with this approach right now is that we don't have access to non-escapable types. When one were to call lock()
, there's nothing preventing the user from taking the guard value and escaping it from the scope that the caller is in. Rust resolves this issue with lifetimes, but C++ doesn't solve this at all and just declares:
The behavior is undefined if m is destroyed before the
lock_guard
object is.
Which is not something we want to introduce in Swift if it's something we can eventually prevent. If we had this feature today, the primitive lock()
/unlock()
operations would be better suited in the form of the guard API. I don't believe we'd have those methods if we had guards.
Reader-Writer Locks, Recursive Locks, etc.
Another interesting future direction is the introduction of new kinds of locks to be added to the standard library, such as a reader-writer lock. One of the core issues with the proposal mutual exclusion lock is that anyone who takes the lock, either a reader and/or writer, must be the only person with exclusive access to the protected state. This is somewhat unfortunate for models where there are infinitely more readers than there will be writers to the state. A reader-writer lock resolves this issue by allowing multiple readers to take the lock and enforces that writers who need to mutate the state have exclusive access to the value. Another potential lock is a recursive lock who allows the lock to be acquired multiple times by the acquired thread. In the same vein, the acquired thread needs to be the one to release the lock and needs to release X amount of times equal to the number of times it acquired it.
Alternatives considered
Implement lock()
, unlock()
, and tryLock()
Seemingly missing from the Mutex
type are the primitive locking and unlocking functions. These functions are fraught with peril in both Swift's concurrency model and in its ownership model.
In the face of async
/await
, these primitives are very dangerous. The example below highlights incorrect usage of these operations in an async
function:
func test() async {
mutex.lock() // Called on Thread A
await downloadImage(...) // <--- Potential suspension point
mutex.unlock() // May be called on Thread A, B, C, etc.
}
The potential suspension point may cause the proceeding code to be called on a different thread than the one that initiated the await
call. We can make these primitives safe in asynchronous contexts though by disallowing their use altogether by marking them @available(*, noasync)
. Calling withLock
in an asynchronous function is _okay_ because the same thread that calls lock()
will be the same one that calls unlock()
because there will not be any suspension points between the calls.
Another bigger issue is how these functions interact with the ownership model.
// borrow access begins
mutex.lock()
// borrow access ends
...
// borrow access begins
mutex.unlock()
// borrow access ends
In the above, I've modeled where the borrowing accesses occur when calling these functions. This is an important distinction to make because unlike C++'s similar synchronization primitives, Mutex
(and similarly Atomic
) can be moved. These types guarantee a stable address "for the duration of a borrow access", but as you can see there's nothing guaranteeing that the unlock is occurring on the same address as the call to lock. As opposed to the closure based API (and hopefully in the future the guard based API):
// borrow access begins
mutex.withLock {
...
}
// borrow access ends
do {
// borrow access ends
let locked = mutex.lock()
...
// borrow access ends when 'locked' gets deinitialized
}
In the first example with the closure, we syntactically define our borrow access with the closure because the entire closure will be executed during the borrow access of the mutex. In the second example, the guarded value we get back from a guard based lock()
will extend the duration of the borrow access for as long as the locked
binding is available.
Providing these APIs on Mutex
would be incredibly unsafe. We feel that the proposed withLock
closure based API is much safer and sufficient for most use cases of a mutex. A guard based lock()
API should cover most of the remaining use cases of needing these bare primitive operations in a much more safer fashion.
Rename to Lock
or similar
A very common name for this type in various codebases is simply Lock
. This is a decent name because many people know immediately what the purpose of the type is, but the issue is that it doesn't describe how it's implemented. I believe this is a very important aspect of not only this specific type, but of synchronization primitives in general. Understanding that this particular lock is implemented via mutual exclusion conveys to developers who have used something similar in other languages that they cannot have multiple readers and cannot call lock()
again on the acquired thread for instance. Many languages similar to Swift have opted into a similar design, naming this type mutex instead of a vague lock. In C++ we have std::mutex
and in Rust we have Mutex
.
Include Mutex
in the default Swift namespace (either in Swift
or in _Concurrency
)
This is another intriguing idea because on one hand misusing this type is significantly harder than misusing something like Atomic
. Generally speaking, we do want folks to reach for this when they just need a simple traditional lock. However, by including it in the default namespace we also unintentionally discouraging folks from reaching for the language features and APIs they we've already built like async/await
, actors
, and so much more in this space. Gating the presence of this type behind import Synchronization
is also an important marker for anyone reading code that the file deals with managing their own synchronization through the use of synchronization primitives such as Atomic
and Mutex
.
Introduce a separate UncheckedMutex
or similar
We could also separate out the unchecked variants of the methods in Mutex
to an UncheckedMutex
of sorts to reduce API surface to improve documentation, auto complete, and push more towards folks opting into the Swift Concurrency world by enforcing sendability constraints.
public struct UncheckedMutex<Value: ~Copyable>: ~Copyable {
...
// Should this have unchecked in the name? It wouldn't
// be discernable from the safe one at the call site,
// but then we're repeating information that's already
// in the type name 🤔
public borrowing func withLock<U: ~Copyable, E>(
_: (inout Value) throws(E) -> U
) throws(E) -> U
...
}
An approach like this means uses of Mutex
only has access to two functions making it easier for developers to choose a sendable checked API over debating if they need an unchecked one.