Open groue opened 2 months ago
Yeah my implemenation is bad. The use of the TaskLocal
applies to all AsyncRecursiveLock
instances within the task. Totally broken.
I did not intend do say what you wrote, of course 😇 Whenever you want to address this, this ready-made scenario might turn helpful!
I'm still thinking of how. First, I'm trying to come up with a test that doesn't involve deadlock in the correct case.
While I continue here, I've realized something. Do you think it is worthwhile having two distinct types? Or should AsyncLock
just be recursive?
🤔 My two cents, after using the semaphore in various contexts:
The name "lock" sounds very traditional. It's not something entirely new. Users come in with a pre-existing mental model. Swift concurrency can introduce new stuff, like awaiting for the lock acquisition, but the mental model is already there.
The mental model already distinguishes locks from recursive locks. Some people like to avoid recursive locks. Having two distinct types sounds ok to me, at first sight.
Traditional recursive locks allow a thread to reenter a critical section. A thread is totally serial, by definition. It's unclear to me how Task fits this, in the context of structured concurrency and task groups. For example, I'd naively assume that in a parallel task group, only one sub-task can acquire a lock at any given time. If more exploration here reveals that Swift Concurrency could break reasonable expectations about things called "recursive locks", then maybe recursive locks must be discarded - or some new name should be invented.
I'd expect those primitives to support Task cancellation.
For AsyncSemaphore, I have two variants:
await sem.wait()
// ignores cancellationtry await sem.waitUnlessCancelled()
// throws if task is cancelledMaybe I should have defined a single wait()
method that deals with cancellation (and throws).
Eventually, I'm not unpleased that there exist two methods, because semaphores are a great tool for tests that want to make sure things are ordered as expected. In such tests, it's sometimes handy to ignore cancellation when waiting for a signal. Eventually, I'm happy that there are two methods. I like my apis to let me do my job without fuss, and AsyncSemaphore, as it is, has the right amount of versatility.
I'm not sure locks are meant to be used in such precise scheduling scenarios, so maybe dealing with cancellation by default should be considered:
func critical() {
// If task is already cancelled, or cancelled while waiting
// for the lock, this throws CancellationError. The lock is
// not acquired, and the error prevents `unlock` from
// being called.
try await myLock.lock()
defer { myLock.unlock() }
// In critical section here
}
There is no Task.sleep
method that does not deal with cancellation. And SE-0304 says:
The general expectation is that asynchronous functions should attempt to respond to cancellation by promptly throwing or returning.
If I see now why semaphores can be an exception, I'm not sure locks should be another.
Here is a sample code that should create a deadlock, but does not:
My interpretation is that after Task 1 has acquired
lock1
,lock2
is completely unprotected, due to the task local. This allows Task 2 to acquire lock2 while Task 1 is supposed to hold it.In the end, there is no deadlock,
lock2
is acquired twice,semaphore2
is signaled when it should not.Semaphores are a fantastic testing tool 🙂