[Pitch] Lock classes in FoundationEssentials

I'd like to move NSLocking, NSLock, NSRecursiveLock, NSCondition and NSConditionLock from swift-corelibs-foundation into FoundationEssentials on non-Darwin platforms, with the same API. FileManager and ProcessInfo already made this move. Nothing changes on Darwin.

Problem

A package that needs only a lock from Foundation still has to import Foundation, and every app that uses it ships all of Foundation. On Android, a minimal arm64 app is 17.7 MB with FoundationEssentials and 65.7 MB with Foundation (Swift 6.4.1 SDK, stripped).

I built 117 popular packages that support Linux against FoundationEssentials only. 21 of them use these classes, and in 7 targets they were the only errors. With the prototype, 5 of those 7 build without Foundation: Swinject, swift-metrics' MetricsTestKit, Hydra, ConcurrencyExtras and needle's NeedleFoundation. On Android, an app using ConcurrencyExtras goes from 65.81 MB to 21.01 MB. The locks themselves add 23 KB to libFoundationEssentials.so.

Prototype

  • Code and tests ported from corelibs. Fork CI passes on Linux, Android (tests on an emulator), Wasm and CMake. Windows didn't run in my fork today, the same as on unchanged main.
  • swift-corelibs-foundation with its own copies removed builds against it on Linux, and its lock tests pass.
  • It also fixes a trap in NSCondition.wait(until: .distantFuture) on Linux.

Source compatibility

Like FileManager, the classes would no longer inherit from NSObject on non-Darwin platforms. In the same 117 packages, no code uses a lock as an NSObject. One package subclasses NSLock (RxSwift), and that keeps working because the classes stay open.

Alternatives

Mutex from Synchronization is the better choice for new code where it's available. But it needs macOS 15 / iOS 18, so packages that still support older Apple platforms can't use it without a second code path, and existing code that uses NSLock would still need full Foundation on other platforms.

Questions

  1. Is the FileManager approach (no NSObject on non-Darwin) acceptable here?
  2. Does this need a full proposal, or does it fit the abbreviated review?
1 Like

I'm not sure I fully understand why this isn't the preferred approach. The Swift Synchronization module is the path forward here, if there are features on the legacy NS-locking types perhaps we should look at bringing those features to Synchronization instead. If the only drawback of Synchronization is the required availability, the difference between swift-foundation and swift-corelibs-foundation isn't super meaningful IMO: availability only matters on Darwin platforms and on Darwin platforms everything comes from the Foundation module as there isn't currently a FoundationEssentials module there. Are you able to elaborate a bit more on why the availability issue makes moving between swift-corelibs-foundation and swift-foundation a necessity?

5 Likes

Thanks, you're right. The gap I still see is recursive locks and conditions. 14 of those 117 packages use NSRecursiveLock outside Apple-only code (Swinject, needle, swift-dependencies, TCA and others), and Synchronization doesn't have anything similar yet. SE-0433 lists recursive locks as a future direction, and condition variables came up in Thoughts on a ConditionVariable type, with concerns about priority inversion. Is that something you'd be open to exploring?

@Alejandro may have more thoughts on future directions for the Synchronization module from the standard library

Ignoring alternative functionality, why wouldn't we generally want to move functionality into the proper Foundation buckets? These buckets were announced years ago, but they've hardly moved in that time. Are they no longer a priority?

The goal is the same - we want the recommended Foundation Swift APIs to share an implementation across all platforms and be available in swift-foundation's libraries. We've been making steady progress on that effort over the past few years (for example, a shared implementation of CharacterSet just landed in swift-foundation).

The difference in this case is that NSLock is not the Swift API that we'd recommend developers use - instead we'd recommend that developers should use Mutex from the Synchronization module. Mutex has an API designed for Swift that takes advantage of all of the latest language features to achieve safety. In that case, I think NSLock can remain in swift-corelibs-foundation because the recommended type with a shared implementation across all platforms already exists below swift-corelibs-foundation, just in Synchronization instead of at a higher level in FoundationEssentials.

4 Likes

Sorry, I can't see the connection between not moving NSLock et. al. and pushing users towards Mutex. How would it do that? NSLock would largely remain as easy to access, and Mutex as awkward to access, as before. So is the intention to force NSLock users to figure out they're paying the cost for the full Foundation import, realize it's due to NSLock, find Mutex as a replacement, update all their code to work with Mutex's API, and drop their Foundation dependency? This seems rather convoluted.

3 Likes

Probably worth some further discussion, but it seems reasonable to me to deprecate NSLock (in Swift) to help direct clients towards using Mutex instead.

12 Likes

Thanks, that makes sense. Deprecating NSLock in favor of Mutex sounds good to me.

Probably a good idea, at least for those platforms that can actually transition. However, a full transition will take years, so moving all NSLock related API to FoundationEssentials is still a good idea. In fact, there are really two things we'd want here:

  1. Move NSLock and all related APIs to FoundationEssentials so the ecosystem gets the benefits immediately.
  2. Add API to the NSLocking protocol that matches the requirements of Mutex. Of course this can't be matched completely due to NSLock not being able to be ~Copyable, but it should get closer than the completely unconstrained withLock currently available in NSLocking. This should make the transition less painful.

I didn't even want to use NSLock back in the Objective-C days due to how inefficient it was compared to a Dispatch semaphore, a pthread mutex, or a spin lock. Depending on the situation, one of those was always a much better, and more performant choice. I can't imagine wanting to use NSLock and friends on Swift.

If Synchronization not being available on older Darwin platforms is enough of a problem to justify expenditure of development work, I have to think that expanding Synchronization's reach a few versions back would have a much better cost-benefit ratio.

NSLock has wrapped pthread_mutex for a while now, even on Darwin. You can see that in the open version, and I've confirmed it by disassembling it on iOS 27.0.1.

2 Likes

It's always wrapped a pthread_mutex since the beginning AFAIK, and it's always introduced overhead due to the class wrapper that made it noticeably more performant to use the pthread_mutex directly. And once Snow Leopard came out and introduced dispatch semaphores, those consistently beat pthread_mutex by decent enough amounts in my testing that I switched to them (for cases where I didn't want a spin lock, that is) and didn't look back. That was seventeen years ago, and now we have better and more modern solutions than the replacements. Let's not prop up these ancient obsolete things.

That's what I thought. I believe the current benchmarks would have Dispatch behind NSLock, much less the other primitives, but it doesn't ultimately matter. We can't wholly abandon the NSLock family, we can only optimize its cost, which is what I suggested. A direct migration to Mutex simply isn't possible; NSLock allows more flexible (unsafe) usage, so developers can't immediately move over.

Really, semaphores? To be clear, I'm not referring to the "queues as locks" technique—that is indeed slow, but semaphores are small, lightweight, and at least back in the day, used to absolutely shred NSLock. I suppose anything's possible, but I'd like to see the numbers, because I'd be pretty surprised, especially on Darwin where it's hard to know whether NSLock may still have Objective-C legacy hanging around in there.

From some benchmarks I (well, Claude) just put together, DispatchSemphore is faster than NSLock and pthread_mutex only in the uncontended case (3.56 vs. 6.47ns per lock). When contended with two or more threads, NSLock is much faster, due to DispatchSemaphore's far greater number of context switches. I'm refining the benchmarks and report and I'll post it somewhere later.

Edit: I've put the full benchmark and report on my Google Drive.

1 Like

That's a fair callout; admittedly, the use cases I was using these for tended toward low contention. I think my point stands though. In the contested cases in your benchmarks, pthread_mutex and os_unfair_lock both beat NSLock handily. In cases where you're expecting a lot of thread contention, then, it would be better to use one of those two directly instead of taking on NSLock's overhead.

Yeah I spent a while in the 2010s switching various internal locks in Foundation and CoreFoundation to (at various points) spinlocks, handoff locks, dispatch_sync, pthread mutexes, and misused dispatch semaphores, but then ended up switching them all again because os_unfair_lock was just better in every way. Unfortunately that wasn't usable from Swift as-is so things were awkward for a while (manual allocation, then OSAllocatedUnfairLock), but now that's sorted out with Mutex.

I really can't say enough nice things about Mutex; it's so luxurious to have a single obviously-correct[1] answer like this.


  1. For the cases it's relevant in at least. If you want asynchrony obviously it's not going to help ↩︎

3 Likes

I was going to suggest this too, actually. NSLock probably should be deprecated anyway, regardless of where it lives.

And it can never change, because NSLock is an Objective-C class and therefore must always be a heap-allocated wrapper around something else. So there is really no benefit to keeping it around in Swift; I'd honestly lean toward deprecating it on Darwin too at this point, but that's probably a decision for Apple to make rather than the Swift community.

1 Like

Mutex is perfectly fine for working with async values, this is the LRU cache I made/use: Updated (or attempted to be at the given path in the message terminator character.