Officially sanctioned async versions of Semaphore/Lock/etc.?

As a concrete example, Python has lock and semaphore primitives for threads, as well as versions specifically meant to be used on the same thread, but for the async case (e.g. asyncio.Semaphore, asyncio.Lock).

I've seen several posts where people have rolled their own (and this seems a complicated endeavour). An async semaphore, in particular, neatly solves the problem of limiting how many concurrent operations can be launched at once, as well as the difficult problem of restricting rentrancy. In short, such primitives would be wonderfully useful.

Are there any plans to make this a standard addition (either to the "standard" library, or possibly some official collection we have sowmewhere)? Or is this done and I'm just not finding it?

If not, can anyone say, "well, version XXX of these as the best I've seen and worth using at this point?"

2 Likes

Specifically for this one we use a simplified variation of the token bucket algorithm in SwiftPM.

3 Likes

Semaphore by @gwendal.roue.

I made a cousin of that, Gate, which is particularly useful for main-threaded code (e.g. SwiftUI).

I haven't found a solid lock primitive (although a semaphore with a count of one serves the same purpose without that much overhead), but it wouldn't be hard to make one if you really want. Though I generally find the need for a simple lock to be less in async code, as there's alternative ways of achieving the same end (e.g. using an actor).

Once the stdlib Atomics module is available it'll be substantially easier to implement these sorts of things (and they'll be substantially more performant). At the end of the day async & continuations aren't magic - you still need an actual "synchronous" lock or atomic variable at the base of it all.

Since actors are re-entrant, an actor doesn’t make it easy to lock. A classic case is asyncSaveStateFile(); the function is async, so it may suspend, but I really don’t want a second invocation to start until the first one is complete.

Said differently, sometimes I want async non-reentrant functions. Locking at the top gives me a way to do this. Yes, a semaphore with a count of one would do this. In my case, performance is not that important. (Use case: I have a server query that takes a second or two. i don’t want someone else starting the same query while the first is in-flight.)

I am surprised that atomic is needed. If it’s all on the same thread, how could you get a race? (And if you’re trying to do this not on the same thread, it’s game over anyway.)

I believe the Python implementations are pretty simple. (Perhaps I should just read their code and see if I can easily convert them to swift.)

Again, a bit surprised this wouldn’t be part of a standard library.

Indeed it sounds like what you want is simply "non-reentrant" actors. The desire for this has come up a bunch of times over the years, and as far as I can tell it's expected to be implemented eventually, although nothing concrete appears to be happening on it right now.

You can achieve the same thing now manually, with explicit continuations. Functionally, non-reentrant actors would just be automating that for you.

And for now it might be wise to do that manually, anyway, so it's more explicit and apparent in your code, as non-reentrant actors are more complicated to reason about and to use correctly (in the sense of not causing performance bottlenecks, deadlocks, and other failure modes).

Kind of like how with GCD you're generally fine using async methods however you like, but as soon as you start using sync versions it gets significantly more challenging to avoid bugs (e.g. someQueue.sync { … } when you're already on that queue? Deadlock).

It's possible that so-called "non-reentrant" actors will actually be re-entrant (what they want to be is non-concurrent), which would eliminate a lot of the dangers, but there's a lot of irrational FUD out there around re-entrant locks and similar patterns, so I'd be surprised if that's what ultimately gets supported. :pensive:

For a generic mutual exclusion primitive - i.e. that can be used across multiple isolation domains, such as separate actors - then you ultimately need a real lock (or equivalent) somewhere in the guts of it. If you're limiting yourself to a single isolation domain, then you don't need mutual exclusion, just continuations. I guess you could have a non-Sendable Semaphore for that specialised case. I haven't seen one in the wild, but it might be possible to derive one from e.g. Semaphore by just deleting the unnecessary parts. The trick might be cancellation - the cancellation handler (for withTaskCancellationHandler(operation:onCancel:)) may be invoked from a separate isolation domain. You might try to ensure that never happens, by restricting how and where cancellation can occur, but I suspect it'd be brittle.

For locks specifically, check out [Pitch] Synchronous Mutual Exclusion Lock

2 Likes

A classic case is asyncSaveStateFile(); the function is async, so it may suspend, but I really don’t want a second invocation to start until the first one is complete.

One of the common solutions is to prohibit calling asyncSaveStateFile at a higher level, e.g. by disabling the button in the UI. Otherwise the UX experience is not ideal anyway: imagine what would happen if save takes a couple of seconds to complete and I press the save button many times in a row - automatically postponing the save call to happen after the previous save completes is not the right approach in this case from the UX perspective.

2 Likes