[Pitch] Synchronous Mutual Exclusion Lock

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 :lock:

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.

34 Likes

:+1:

It'd be nice to have essentially OSAllocatedUnfairLock on all platforms.

Opportunistic locking

I like OSAllocatedUnfairLock's withLockIfAvailable more than tryWithLock, because the "try" keyword has established meaning in Swift and it doesn't match the usage here.

It's also better for auto-complete since it provides a common prefix for the related methods.

Non-closure-based locking

I get that withLock etc is safer, but unfortunately it has downsides - e.g. Swift won't let you assign to a let value from the parent scope:

let lock = OSAllocatedUnfairLock(initialState: 0)
let value: Int

lock.withLock {
    value = $0 // ❌ Cannot assign to value: 'value' is a 'let' constant
}

print("Value: \(value)")

Workaround:

let wrappedValue = 0
let lock = OSAllocatedUnfairLock()

let value: Int

lock.lock()
value = wrappedValue // Or, of course, you could `let value = …` here.
lock.unlock()

print("Value: \(value)")

Recursive call behaviour

Part of the point of a standard library type is to standardise behaviour. Even if it's a programming error to try to re-enter the lock, it's a pretty common one - arguably an easy one to make, in non-trivial scenarios - so it's still important to have deterministic, consistent behaviour.

Is it possible to have it consistently fatalError, across all platforms?

Read snapshots

Sometimes - for copyable value types - it's convenient to be able to grab a snapshot of the value. You can do this manually (like in the example used in "Non-closure-based locking", above) but it'd be handy to have a convenience property or method for it. e.g.:

var contents: Value { get }

Having that sort of interface helps ensure you don't accidentally do anything else while holding the lock, when all you need is to read the currentish value (e.g. for logging).

Async compatibility

I've long been frustrated by the lack of good mutual exclusion primitives that work in both synchronous and asynchronous contexts, because often a given code-base is a mix of the two but the mutual exclusion requirement is universal.

Using this proposed lock from an async context is dangerous because it can block for an indeterminate length of time, and can lead to whole-program deadlock.

Is it possible to support both synchronous and awaitable interfaces on the same lock type?

Naming

I prefer the name Lock. Simpler and more to the point. I haven't encountered any of the misunderstandings (first- or second-hand) that are mentioned in the proposal as to why Lock isn't used.

But I have encountered plenty of folks who know they need a lock, so they search for "lock", and don't necessarily even know what a "mutex" is (I didn't realise it's perhaps coming back into fashion, with Rust using it, but otherwise it seems slightly archaic to me).

For example, the Wikipedia page for "Mutex" auto-redirects to the Lock page.

9 Likes

This is a great addition, and thanks for working on it @Alejandro.

Especially in the standard library the lack of a lock type has been rather problematic, leading to random ad-hoc solutions in there as well as in the wider ecosystem (though the server ecosystem mostly relying on NIO's lock).

I agree that the "with" style is preferable for safety, and I understand the unsafety argument about lock() / unlock() but would like to understand more about it.

One problematic situation we can't handle with a with style API is the following:

mutex.withLock { state in
  switch state {
  case .mustWait:
    await withCheckedContinuation { cc in 
      // FIXME: need to unlock mutex, but can't
      state = .waiting(continuation)
    } 
    return
  }
}

where the checked continuation's body runs synchronously, but we need to unlock the mutes during that closure.

I see the implementation actually provides underscored _unsafe{Un}Lock(), would the plan to be to rely on those underscored unsafe implementations until we're able to provide the "guard" style API? I'm specifically asking for usage inside the swift standard library and the wider Swift server ecosystem. Within the library we can get away with removing the _ APIs at some point... but if they become used widely, they'll practically be expected to remain there -- should we offer them without the underscores but with the "unsafe" wording to provide the necessary "watch out!" signal when using them?

7 Likes

Very excited for this!

I wanted to point out one more potential shortcoming of limiting this to a closure-based API. Take this for example:

struct ArrayWrapper: ~Copyable {
  private let lock: Mutex<[Item]>

  func subscript(index: Int) -> Item {
    _read { 
      lock.withLock {
        yield $0[index] // ❌ compilation error
      }
    }
    _modify {
      lock.withLock {
        yield &$0[index] // ❌ compilation error
      }
    }
  }
}

It would help if there were some kind of lower level UnsafeMutex or what-have-you that provided lock methods to accomplish this.

struct ArrayWrapper: ~Copyable {
  let lock: UnsafeMutex
  var values: [Item]

  func subscript(index: Int) -> Item {
    _read { 
      lock.lock()
      yield values[index] // ✅
      lock.unlock()
    }
    _modify {
      lock.lock()
      yield &values[index] // ✅
      lock.unlock()
    }
  }
}

I also prefer the name Lock, simply because it's the name I'm more used to. I suspect that most folks who have used Objective-C and Swift are more likely to know the term Lock than Mutex. But perhaps that's not necessarily an argument in favor of Lock...

3 Likes

if i remember correctly, this will deadlock if the caller throws an error, since that will bypass anything after the yield.

1 Like

Ah! Well then! Shows me for writing ideas off the cuff. :sweat_smile: Would swapping it out for a defer { lock.unlock() } before the yield fix that problem?

1 Like

yes, but in my mind that is an example of a mistake any one of us could make which makes me less than enthusiastic about exposing lock and unlock.

5 Likes

Isn't this just:

let value = lock.withLock {
  $0
}

?

Attempting to reacquire the lock after acquiring it is an illegal and unsupported operation. We cannot guarantee what platforms will do in this case. (Well, on Linux we do guard against operation and fail, but there's no guarantee the check will always exist for example). If we wanted consistent behavior it would be a non-trivial amount of memory (need more information to store in the Mutex) and performance overhead tracking this specific case.

This is a pretty dangerous operation. Not all values can be loaded and stored in a single transaction. The acquired thread may be writing values to the state and another thread called this snapshot API reading in between values of the state rendering the state value you just read unusable. Reading and writing the state must acquire the lock.

EDIT: I just came to the conclusion that you may have been talking about something like:

var contents: Value {
  withLock { $0 }
}

? This would be safe, but it hides away the locking details from callers that I think should be explicit. Hiding the locking operation may lead to some bugs of accidental recursion or similar.

4 Likes

I think that's a fair & valid point, though I would certainly hope anyone using anything prefixed with Unsafe in production-ready code would take more care and testing than my off-the-cuff forum post; after all, the illustrated problem still exists regardless of whether the proposed solution had a bug in it, as it can be written without said bug. If the proposed Guard API were coming along with the rest of it, this would be a moot point, but having to wait for that would be a bummer when the tools technically exist but are just inaccessible.

1 Like

I'd say so. Although explicit lock() / unlock() are also useful to have.

I remember using both NSLock & NSRecursiveLock. The recursive variant was nice to have.

We also don't currently guarantee that _read and _modify will get resumed on the original thread.

8 Likes

In trivial cases perhaps, but not generally. e.g. consider where you're extracting multiple values from a struct held under the lock. Returning the whole struct might be unreasonable (semantically, if not also in terms of performance, or correctness if some member variables are non-trivial value types).

Perhaps there's always some workaround via returning tuples or whatever, but I find that doesn't scale well in practice. It's also awkward, ergonomically. Insert broccoli-man "I just want to assign a value" (for any other Xooglers out there :grin:).

It'd be nice if there were a way to tell the Swift compiler "this closure argument is always invoked if I don't otherwise throw", but as far as I'm aware there is not.

Correct. I didn't spend any time on the name, but indeed there might be better options that better convey that the operation is safe.

1 Like

It's been mentioned peripherally, but it would be good if the proposal was crystal clear about how it interacts with Swift concurrency. Should they be used together? It seems very useful for exposing internal state to nonisolated methods or ensuring atomicity. Additionally, is this type be favored with any sort of optimization versus older lock types?

1 Like

Mutex will have a conditional conformance to Sendable when the underlying value itself is also Sendable .

Are there other protocols Mutex could conditionally conform to when the underlying value also witnesses it, e.g.: Equatable, Hashable so users of Mutex would not have to provide custom implementations of those protocols? I'm not sure I can think of any good examples or use cases.

1 Like

Yes, they can be used together. It should be perfectly fine to take a lock in an async context and do a relatively quick operation with the guarded state (assuming it's relatively uncontended and other threads aren't holding the lock for long periods of time). There is unfortunately no one best definition of what you should and shouldn't do while holding a lock and when it's best to use one in both sync and async contexts. It's up to developer to profile and determine for themselves how and when to use these kinds of primitives in their code bases.

Yes! This mutex is stored inline and does not require an extra allocation that older types had to do. On Darwin and Windows platforms that's mostly the biggest advantage this type has because they are just calling into system implementations to lock and unlock. For Linux, we use the futex primitives to make a relatively simple mutex type that's really cheap to acquire and release in the uncontended case. This implementation should prove to be at the very least a small win over implementations that use pthread_mutex_t.

10 Likes

I'm not sure these kinds of conformances makes sense for synchronization primitives. We intentionally didn't provide them for atomics because == and hash(into:) would secretly be doing atomic operations behind you back and, unless specified in documentation, would use an opaque memory ordering. The same can be said for this type where comparing two mutexes would take 2 locks to compare a value and then release both locks. Imagine the scenario where we successfully are able to take the lhs lock, but another thread has ownership of the rhs lock. Now the call to == is blocking on acquisition of rhs which feels pretty poor in my opinion.

6 Likes

This is a somewhat more interesting question than it is with Atomic. The mutex meaningfully limits access to the stored value to specific critical regions, which admits a plausible alternative design for mutexes on non-Sendable types in which the mutex simply maintains an invariant that the value is a disconnected region. For example, code like this would be allowed:

let mutex = Mutex<MyClass?>()
...
func stealValue() -> MyClass? {
  mutex.withLock { currentValue in
    let value = currentValue    // transfer the current value out, knowing that it's disconnected
    currentValue = nil          // replace it with a value in its own (trivial) disconnected region
    return value
  }
}

We can't currently express this, but it's something we're actively working on. It's such an important part of region-based analysis that I wouldn't really call the feature complete without it.

Now, you could fit this into your proposed Sendability scheme without modification by requiring disconnected regions be wrapped in some sort of Disconnected<T> wrapper type. Since values in a disconnected region can be transferred between concurrency domains, Disconnected<T> would be Sendable even if T is not, and so Mutex<Disconnected<T>> would work as a way to express this pattern. But users would then have to deal with wrapping and unwrapping the Disconnected value, which I would expect to be pretty awkward, and a non-Sendable Mutex seems pretty pointless to me. So I would suggest instead that you allow the underlying value to be non-Sendable but disconnected and then make Mutex unconditionally Sendable — or, at least, leave yourself room to do that when disconnected regions have been added to the language.

(Disconnected regions don't help with atomics because the disconnected invariant is incompatible with copying, whereas typical atomic access patterns necessarily involve copying the stored value. There are lock-free patterns that can maintain an overall invariant of the atomic holding the disconnected region, but it's holistic across the pattern, not something that locally holds on individual operations; thus it would have to be encapsulated.)

4 Likes

Great work! Just one point to mention:
I would prefer to use something else instead of try prefix (e.g. withLockAttempt) and make function rethrowable. In such way if body is not throwable then function itself also doesn't need try keyword when called.

The function is written using the new typed throws. If body doesn’t throw, the generic parameter E will be Never which means that you don’t have to call the function with a try.

5 Likes

Conversely, when the the type guarded by the Mutex is Sendable, then it isn't clear to me that we need to also constrain the withLock body to be @Sendable. The body will execute on the calling thread, and if the guarded value's Sendable conformance is correct, it shouldn't be possible to leave behind any thread-confined state in the guarded value when it's done executing. Overload resolution costs willing, maybe we can do away with the "unchecked" distinction for Sendable mutex payloads.

2 Likes