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.