SE-0433: Synchronous Mutual Exclusion Lock

In my opinion, the Swift standard library should not be limited by the considerations of one application domain. From Swift.org:

Swift is a general-purpose programming language that’s approachable for newcomers and powerful for experts.

There is a big effort to support Swift on servers, to build distributed systems, to build firmware for embedded systems, and everything else that a general-purpose programming language can do.

Every day, more of the Swift compiler is built in Swift. We know that Apple is using it in their Secure Enclave processors, the swift-collections package contains complex data structures written in Swift, Foundation is being rewritten in Swift, and personally I hope one day we'll see other major open-source projects (such as WebKit) begin a transition to Swift. Community member @LiarPrincess even wrote a Python VM in Swift.

These are all exciting projects, and conceivably, projects of this kind may have need of mutexes (I know I have in my projects - one example). They are a fairly standard construct in computing.

I don't believe that introducing a native Mutex type (or any API for that matter) implies any kind of endorsement outside the use-cases it is specifically designed to support.

It is worth noting that Apple platforms offer at least 3 mutex/mutex-like APIs already - OS(Allocated)UnfairLock, std::mutex, and pthread_mutex - so this doesn't fundamentally offer anything that developers don't already have easy access to, and since it lives in a separate Synchronization module, developers will have to go looking for it explicitly (just as they do for the other types).

12 Likes

I think that having Mutex as part the Synchronization module as proposed strikes a good balance.

The Mutex type is part of the standard library, but it takes that additional import statement to make it available.

This helps newcomers avoid being led towards using it by finding it accidentally with code completion, but makes it easily availability when needed.

8 Likes

Personally I have the opposite opinion. I think the lack of a lock alongside Swift Concurrency has been largely detrimental to the language. It has sent the wrong message that actors is the solution to all concurrency problems and the logical consequence has been an overuse of actors and asynchronicity. In this context, the main point of a lock to me is being able to remain synchronous, which is not something you could ever achieve with actors. Therefore, I can't wait for this type to be available to all :)

3 Likes

Async locking (e.g. as performed by actors) is totally different at the implementation level from sync locking. Expecting a single lock to provide both sync and async locking would preclude using any existing lock implementations (which are usually very tightly optimized and kernel-integrated) and probably make it impossible to provide expected features like priority inversion avoidance for synchronous lockers.

8 Likes

This should now be resolved with SILGen: Emit references to noncopyable global storage directly as a borrow. by jckarter · Pull Request #73041 · apple/swift · GitHub. I can try to regenerate toolchains so folks can try it out again.

Edit: Here they are which should includes the fixes:
macOS: https://ci.swift.org/job/swift-PR-toolchain-macos/1223/artifact/branch-main/swift-PR-71383-1223-osx.tar.gz
Linux (x86_64): https://download.swift.org/tmp/pull-request/71383/792/ubuntu2004/PR-ubuntu2004.tar.gz
Windows: https://ci-external.swift.org/job/swift-PR-build-toolchain-windows/1213/artifact/*zip*/archive.zip

7 Likes

Using this new toolchain, I did some testing with Mutex with few types on top level on macOS and everything works great now.

Thanks !

2 Likes

Hi all, I've updated the proposal to include @FranzBusch's suggestion to mark the return type for the withLock and withLockIfAvailable as transferring. This means the return type no longer needs to Sendable. I've also added a snippet to the API documentation that attempting to reacquire the lock on the same thread who acquired will guarantee that it will not reacquire the lock and must either deadlock/panic/or some other behavior the platform decides (as long as it doesn't reacquire).

20 Likes

Hello,

I've been playing with my own Mutex type, inspired from the one reviewed here, while migrating a code base to strict concurrency checking. I'm still in Swift 5.10, so I don't use non-copyable types and transferring parameters. Still, I find the narrow api surface of Mutex pretty limiting:

I have countless tests that use mutexes in a pattern similar to the one below:

let valueMutex = Mutex(0)
await stuff { // Sendable closure
  valueMutex.withLock { $0 = 42 }
}
XCTAssertEqual(valueMutex.withLock { $0 }, 42)

This verbosity makes me beg for a convenience value property:

let valueMutex = Mutex(...)
await stuff {
  valueMutex.value = 42
}
XCTAssertEqual(valueMutex.value, 42)

But this property is dangerous, because it allows racy code such as mutex.value += 1.

I beg the community to explore compiler-level solutions that would make it possible to declare a convenience property that disallows the unsafe mutations:

mutex.value      // OK
mutex.value = 42 // OK
mutex.value += 1 // Compiler error

This would greatly improve the ergonomics of this type.

Of course, making mutex.value += 1 safe would be even better.

Maybe other solutions have been explored, that reduce the need for an explicit call to withLock { ... }? I'm curious.

1 Like

I think that this would be a bad direction for the standard library to go in - you can add those convenience properties as an extension to/wrapper around Mutex for your own use, but completely hiding the fact that a synchronous lock needs to be acquired to perform an operation seems dangerous for the standard library to do, in the same vein as suspension points being made explicit at the source level and not done implicitly even when the compiler absolutely could do so.

1 Like

I will sure do. I won't make a generality of my particular case, but if many developers start to add this unsafe property, it's reasonable to question whether the type fits its use cases. It is possible to ask this kind of question right now, even before the type is publicly released, before we have statistics about the behavior of the crowd, by sharing early experience, performing educated guesses, and using empathy. In my own experience, tests are the context where I've needed the (currently dangerous) property the most.

1 Like

The problem with adding a .value member is that it becomes tempting to write code such as:

if mutex.value > 5 {
  mutex.value = 0
}

But of course, since each call to .value acquires the lock, other code could run between those statements:

if mutex.value > 5 {
  // mutex.value may be <= 5 here!
  mutex.value = 0
}

By having the explicit withLock closure, the operation becomes atomic - you don't give other threads a chance to read or modify the value until you are completely done with it.

10 Likes

Ah, you're right, @Karl

@jpsim, do you think it would be possible for a linter such as SwiftLint to detect such misuses? In which case, the standard lib could ship with the convenience property (along with a detailed documentation about wrong usage), and rely on linters for avoiding the mistakes?

This would be better than letting the devs exposed to the dangers of their own extension (which, I predict, will happen if the stdlib does not ship the property).

In my own experience in API design, I've seen myself hoping that users understand that if an obvious api is not there, there is a good reason for it - and they should not add it. In reality, that's not how users behave. They just add the dangerous api in their custom extension, and I stay there, helpless, watching them shot themselves in the foot. Surely I can say they're wrong. But I can also consider that my api might have an issue in need for a solution.

2 Likes

It doesn't completely rule out the misuses though (just makes them less likely), as someone could still write:

if (withLock { value > 5 }) {
    withLock { value = 0 }
}

But in your example case, the critical sections are clearly visible, and so is the logic flaw. That's why that such a misuse does not need to be addressed. We only need to think about misuses that look like they are correct:

// Incorrect, but looks correct 👎
if mutex.value > 5 {
  mutex.value = 0
}

// Incorrect, and looks incorrect 👍
if (mutex.withLock { value > 5 }) {
    mutex.withLock { value = 0 }
}

That's why I'm conscious I'm asking for a dangerous api, why I'm trying to argue that people will add it if it is missing, why ignoring this prediction is creating problems, and why I'm suggesting to look for solutions.

This should not jeopardize the success of the proposal. I just wish to read some acknowledment that an api limited to withLock, despite its high level of correctness, has ergonomics issues that have people look for incorrect solutions. The sooner this problem is noticed, the better.

3 Likes

I see what you mean. If there's a fear that users will provide .value of their own maybe we could do it first, marking it with "unavailable", and having a failing implementation if users manage to call it somehow. Perhaps along with some .nonAtomicTransactionallyValue (bike-shed name) for those cases it's actually useful.

:sweat_smile: Imagination has no limit: if value is marked as unavailable, people will write mutex.get() and mutex.set(...) in order to avoid the proliferation of withLock { $0 } and withLock { $0 = ... }. I'd rather ship the Mutex.value property, and rely on linters to detect the misuses (unless another solution is found).

It could be a warning – annoying enough to discourage people using it whilst "available" enough to demotivate people investing into their own alternatives.

1 Like

If we were to provide a "convenience" API, exposing the value as a property is definitely too abuse-prone, but maybe we could provide something that looks like the Atomic API, with methods like mutex.load(), mutex.store(x), maybe even RMW math operations if the value conforms to the right properties, and so on. Someone might still be tempted to write mutex.store(mutex.load() + 1) but it's at least more apparent in that line of code that that will end up doing two separate mutex accesses (and it's actually longer than mutex.withLock { $0 += 1 }, so it's less "convenient" to do so overall).

9 Likes

I would worry a little bit about the attractive nuisance of providing such API--both from a "making it easy to write incorrect code" perspective, but also from a "if that's what you're doing, you probably should be using an atomic instead" perspective.

5 Likes

I'm not terribly convinced that convenience APIs are desirable either, but I think it is relatively common that reading a value is safe to do in a grab-and-go way, even though updating the value may require a longer transaction. There might be an argument there for providing load but not any corresponding mutation conveniences. There's also the fact that our Atomic type only strictly supports types that the platform can atomically update without locks, so there might be an argument there for Mutex providing a similar API so that it can be dropped in as a replacement for Atomic when locking is acceptable and a target platform doesn't have native atomic support for a type.

9 Likes