Task Gates for the Standard Library

I finished up work today on a little package that helps manage reentrancy. It really got me thinking. This is a problem that I have encountered, regularly, for years at this point.

A great motivating example is a “simple” cache.

nonisolated final class Cache {
	private var value: Int?

	init() {}

	nonisolated(nonsending) func get(orFill: nonisolated(nonsending) () async -> Int) async -> Int {
		if let value {
			return value
		}

		// this is a read-dependent async operation that could
		// result in overlapping calls to `orFill`.
		let newValue = await orFill()

		self.value = newValue

		return newValue
	}
}

The problem is that while this implementation might be simple, it is incorrect. There are many ways of addressing this. But doing so in robust way that does not involve an unstructured Task is surprisingly complex. That’s the whole reason I felt it worthwhile to put a package together. And I'm not alone!

I also find it fascinating that we both, independently, settled on the term "gate".

Here's what a gate-based solution would look like:

nonisolated final class Cache {
	private var value: Int?
	private let gate = AsyncGate()

	init() {}

	nonisolated(nonsending) func get(orFill: nonisolated(nonsending) () async -> Int) async -> Int {
		// the gate allows only a single task to enter at a time, forcing any others
		// to wait in a queue until the gate is released.
		await gate.withGate {
			if let value {
				return value
			}

			let newValue = await orFill()

			self.value = newValue

			return newValue
		}
	}
}

While I initially chose "lock", I have been convinced that this is not the correct term. While fairly similar, programmers generally understand what locks do, and this construct does not quite work the same way. And because reentrancy as a concept can already be a tough thing for people coming to Swift's concurrency system, I'm not sure overloading it here helps.

In my implementation, I chose to keep AsyncGate as non-Sendable. This was to more strongly guide users into thinking of such a tool as per-actor.

I think this is a generally-useful tool and something that belongs in the standard library.

The most obvious objection to this is that it inherently opens the door to deadlock. I think this is a perfectly acceptable trade-off given the utility and how frequently the problem presents itself.

But, I'm definitely curious to hear what others think, and would love to get your opinions.

7 Likes

Just some random thoughts about this:

I think there was a very lengthy thread about this kind of API already (IIRC it was related to the Semaphore library you‘ve mentioned).

From what I can remember, priority inversion was a thing to be considered, since you make it possible that higher prio tasks have to wait for lower prio tasks to complete. This should now be implementable, even without stdlib, since SE-0462 landed.

Even though personally I‘ve always needed the singular version of this (as in only one task gets to pass the gate), a more general version where you can specify the limit of concurrent entries could be considered. I feel like there have been some questions about this behavior as well on the forums.

FWIW, I also have an undocumented, unfinished version of this API as well, which I made because I wanted to try the new priority escalation APIs: GitHub - ph1ps/swift-concurrency-limiter

I think this API is similar to the withTimeout/withDeadline APIs as in, people keep re-inventing it and creating libraries (which I am not against, I am one of these people), where it‘d be nice to have one endorsed, maybe not-yet-perfect implementation somewhere, maybe swift-async-algorithm, until the more perfect version lands in _Concurrency.

Edit: I meant this thread: Using semaphores in actor code

Edit 2:

Interesting. I think when I previously had to use this, it had to be Sendable, but I don‘t remember if it could‘ve been rewritten to be isolated to an actor…

6 Likes

I‘m using a similar construct—and copy it from project to project because it‘s really missing in _Concurrency or Synchronization.

I was stunned by your decision to do without Sendable and really have to think about that for a while. The idea to leave isolation up to the „gated“ type at first seams dangerous, but also removes a lot of complexity from the Gate. :thinking:

One thing that I nearly always depend on is task cancellation. How would a client that is suspended in withGate handle cancellation?

1 Like

I don’t think this is the right way for the language. This is more like a workaround, the solutions is to have non reentrant semantics for actors be an opt in.

This has been a proposed state ever since the first actor proposals, we’ve just not gotten to it. Making it a type you need to hold and wrap code in means we’re reinventing locks basically which is what actors already kinda are. I don’t think this is the right way, duplicating the semantic meanings one has to understand.

6 Likes

What is the status from language Steering group (or any other relevant swift work group) regarding pursuing this? To me it feels like it ought to be a pretty high priority feature. I too have missed this many times while writing async Swift.

Steering groups don't "do the work", including the LSG which is responsible for reviewing proposals but it's not the same people who do the work.

It's hard feature so prioritizing it has been difficult given other requirements. I remain personally interested and have many ideas here but it's not something I've been able to just make happen in my so-called spurious free time :sweat_smile:

1 Like

Oh thank you so much for this reminder! I went back and re-read the thread and there was some very interesting stuff in there.

Ahhh, and thanks for this too! I actually had your repo here bookmarked to return to, but I’d forgotten about it.

This is really fascinating stuff! I’ve begun an investigation to see if I can incorporate these APIs into my own implementation. You might be right that some parts of this will end up needing to become Sendable…

To me, a Sendable version seems dangerous. Because it makes it much easier to share a gate across actors, which increases the possibility of deadlock. Or maybe I’m missing what you were getting at?

I don’t immediately see how cancellation would be problem, but it’s always tricky so maybe I’m missing something. Cancellation should look like any other thrown error. If an error is thrown that hits the gate, it first opens it before throwing itself.

I’d love to read more on what your thinking is here! I totally see how something like this could be incorporated into the language. And I get what you are saying about multiple semantic meanings. Though data race safety and reentrancy seem to me like they actually are independent concepts. I guess because it all comes back to actors, they are ultimately closely related?

The very first actor proposal has a long section on reentrancy, it also explains the potential for reentrant(never): swift-evolution/proposals/0306-actors.md at main · swiftlang/swift-evolution · GitHub just that we've still not gotten around to it yet.

3 Likes

Thank you @ktoso for that pointer. The discussion in the proposal was great. Aside from being generally interesting, it does help to make the case for why this makes sense as a language feature specifically - the ability, in some cases anyways, to statically detect and even avoid actor deadlock.

I’m hopeful that we see this materialize one day!

1 Like

+1 that reentrancy policy will be built into the language at some point.

I want to share that Swift-lang repos share similar need like: