GH-111693: Propagate correct asyncio.CancelledError instance out of asyncio.Condition.wait()#111694
Conversation
gvanrossum
left a comment
There was a problem hiding this comment.
I have a few nits, but I stopped reviewing after realizing the overlap with gh-112201 -- can you clarify what your intention is with these two PRs? I'd recommend not submitting overlapping PRs. :-)
Ok, various projects have different policies, I see. Some other projects I contribute to desire each PR to address a specific feature only. This PR is there because I noticed inconsistencies with error handling in the locking primitives. This came about as I was implementing my experimental "Task Interrupt" feature in https://github.com/kristjanvalur/py-asynkit (you may recall our discussion earlier this year where I was suggesting something like that). The other PR came about as part of something different. In my above project I am also working on experimental priority scheduling and as part of testing that, I needed to re-implement the locking primitives. This is where I realized that the Condition variable can exhibit wakeup starvation when there is a race with cancellations. There are also two separate defects that they address. If you like, and if you think these issues are worthwhile, they can be rolled up, but ordinarily I would have just reworked/rebased one or the other in case either was accepted/merged. Please advise :) |
Fixed typos Co-authored-by: Guido van Rossum <gvanrossum@gmail.com>
Okay, I will review in chronological order, i.e. this one (the oldest) first. But you could have said something -- you are literally creating a merge conflict with your own code here. The second one could be marked Draft until the first one has landed and been merged into the second one. |
gvanrossum
left a comment
There was a problem hiding this comment.
I haven't reviewed the tests yet. I will do that after we've reached agreement over what the main code should be.
| self._wake_up_first() | ||
| raise | ||
|
|
||
| self._waiters.remove(fut) |
There was a problem hiding this comment.
Now I look at this more carefully, I think I like the original version using nested try blocks better. Reading the new code I have to correlate the two separate removals to prove to myself that as soon as we've awaited fut (whether it got a return value or was cancelled) it is removed from the list of waiters. And that's why it was written like that originally.
(Note that try blocks cause no overhead unless an exception is actually raised -- the compiler even duplicates the finally block to ensure this.)
There was a problem hiding this comment.
Thanks, I guess it makes it simpler to reason about, good to know that nested blocks have no overhead anymore.
| except BaseException: | ||
| self._waiters.remove(fut) | ||
| if not self._locked: | ||
| # Error occurred after release() was called, must re-do release. |
There was a problem hiding this comment.
This comment makes things more mysterious to me, not less. If we're precise, we get to this exception handles whenever await fut raised something -- that can currently only be a cancellation, unless something reached into private data, extracted the future, and called set_exception() on it. (Apparently this is what you're planning to do in your own library?)
If I had to explain in plain English what was going on here, I'd have to say something like "The acquiring task was cancelled after the lock was released, but before it resumed execution. (This is one of these things that's easy to forget is possible when casually reading async code.) We're going to re-raise the cancellation here, but we should first wake up the next task waiting for the lock, if there are any." I guess you summarized the condition as "after release() was called", which I think is too concise.
Thinking all this over, I am also thinking there's an incredibly subtle invariant going on here regarding self._waiters. Skimming the code of release() and _wake_up_first() one starts wondering, "what if two or more waiters are cancelled, the first waiter wakes up (being cancelled), and its _wake_up_first() sees that the second waiter is also cancelled (and hence done), so it doesn't wake up the third waiter." My guess is that in this case the second waiter will also wake up and call _wake_up_first(), at which point the (originally) third waiter will be woken up. But maybe this deserves a reassuring comment somewhere?
Maybe there's a way of thinking about this that makes it easier to prove to oneself that the code here is correct, without such a "global" analysis? Maybe the invariant is something like "every cancelled future in the list corresponds to a task that will eventually be woken up, at which point it will remove itself from the list and call _wake_up_first(). Although that immediately makes me wonder if it is possible for the second waiter to be woken up first. Fortunately in that case things also work out, once the first waiter is also woken up.
There was a problem hiding this comment.
Thank you for your thoughts. Yes, the comment was too concise and your version is better, but thinking more about it, it is not really true either. This here is not a case of "cancelled after lock was released". this is because release() does not do any modification of the _waiters queue itself. All it does is
- set
self._locked=False - Ensure that the Task owning the future at the head of the queue will wake up.
I go over the self._waiters in a comment below. Here is how I follow the logic:
_wake_up_first()always wakes up the same Task, at the head of the queue.- Only when a task resumes execution, does it remove itself from the queue, opening the possbility for a different Task to run. When it resumes execution, it either claims the lock for itself, or gives the task at the new head of the queue the chance to claim it.
- When a task isn't about to claim the lock, and
self._locked()is False, it is always safe to call_wake_up_first(), even multiple times.
Looking at the case you mention:
- two waiters are cancelled. They still are in the
self._waitersqueue. - first one wakes up. Removes itself from
self._waiters. Decides it doesn't want the lock, sees thatself._lockedis False (no one else has it), and so calls `self._wake_up_first(). This is a no-op, since the future has already been cancelled. - Second one wakes up. Removes itself from
self._waiters. Decides it doesn't want the lock, seelf thatself._lockedis False, callsself._wake_up_first. - If there was a Task at the head, then it is now awoken.
Not sure how to formulate that into an invariant, but something like this:
if not self._locked and not claiming_lock, and self._waiters:
# ensure that the head of the queue will run
head = self.waiters[0]
head.set_result(True)In other words, if the lock is not claimed, then the head of the queue, if any, should no longer be blocked.
| self._waiters.remove(fut) | ||
| except exceptions.CancelledError: | ||
| await fut | ||
| except BaseException: |
There was a problem hiding this comment.
I'm still not very comfortable with widening this exception net for the purpose of sneaking in support for your library. If we want that to be supported we should make it an explicit feature, everywhere, rather than just tweaking an except clause here and there until your tests pass. Without any comments explaining the intended guarantee, what's to stop the next clever maintainer from narrowing the exception being caught to CancelledError again?
There was a problem hiding this comment.
Fair point. I quite understand that you are reluctant to touch this just to humour an eccentric experimental library which is trying to push the envelope of the original design. And I'm happy to have this rejected as long as we have had the chance to discuss it. I'll add a comment here in the mean time, just for safety.
There was a problem hiding this comment.
IIUC you're planning to make your library to use a subclass of CancelledError, so you don't need this to be more general anyway. So I'd like to see CancelledError here.
There was a problem hiding this comment.
Sure. Of course, IMO, it would be even greater if in some future version, we would have something like:
class InterruptError(BaseException):
pass
class CancelledError(InterruptError):
passbut that is future music :)
| self._waiters.remove(fut) | ||
| except exceptions.CancelledError: | ||
| if not fut.cancelled(): | ||
| await fut |
There was a problem hiding this comment.
Same thing as before -- I actually like the nested try/finally phrasing better. Also, again, sneaky about catching all errors.
There was a problem hiding this comment.
Fair point, especially if it costs nothing.
Thank you, yes, I was aware of my own merge conflict but not aware that this was bad form. I'll make sure to keep PRs non-conflicting in this project in future :) |
Co-authored-by: Guido van Rossum <gvanrossum@gmail.com>
gvanrossum
left a comment
There was a problem hiding this comment.
Okay, you've convinced me. Now I have to review the tests for this case.
| self._waiters.remove(fut) | ||
| except exceptions.CancelledError: | ||
| await fut | ||
| except BaseException: |
There was a problem hiding this comment.
IIUC you're planning to make your library to use a subclass of CancelledError, so you don't need this to be more general anyway. So I'd like to see CancelledError here.
| self._waiters.remove(fut) | ||
| # Ensure the lock invariant: If lock is not claimed (or about to be by us) | ||
| # and there is a Task in waiters, | ||
| # ensure that that Task (now at the head) will run. |
There was a problem hiding this comment.
Nice comment! It doesn't immediately alleviate my fear that the future now at the head is already cancelled, and nothing will be woken up. The explanation is, of course, that all cancelled tasks in the waiters list are already woken up and are just waiting to run. Maybe you could add a hint about that?
(And maybe reflow the text, pretty please? In comments, you have to do the rendering in your head. :-)
| if not fut.done(): | ||
| self._value -= 1 | ||
| fut.set_result(True) | ||
| # assert fut.true() and not fut.cancelled() |
There was a problem hiding this comment.
I'd remove that -- I don't think there's such a thing as fut.true() and the semantics of set_result() ensure that if the future was cancelled it will raise an exception.
There was a problem hiding this comment.
(Ditto if it already had another result, or an exception.)
There was a problem hiding this comment.
Right, I added that as an explanation to what the state ought to be, to mirror the other test we make, but it is really redundant (and incorrect).
gvanrossum
left a comment
There was a problem hiding this comment.
Tests look good, with one nit.
| if not fut.done(): | ||
| self._value -= 1 | ||
| fut.set_result(True) | ||
| # assert fut.true() and not fut.cancelled() |
There was a problem hiding this comment.
(Ditto if it already had another result, or an exception.)
| """Test that a cancelled error, received when awaiting wakeup | ||
| will be re-raised un-modified. | ||
| """ |
There was a problem hiding this comment.
Please convert the docstrings on the tests to comments -- test docstrings tend to be printed by the test runner, messing up the neat output. Note how no other tests here have docstrings.
|
Just one more thing. There's no test for the fix to |
|
A bit busy at the office these past days, but I'll revisit this on the weekend. Thanks for your review already. |
|
Some of these comments are anachronistic, should I update them? I.e. replace "coroutine" with "Task" |
I'm not sure how to add such a test. It is really re-affirming the assumption that the semaphore is always To test this case, I would have to do a |
|
I've modified the |
gvanrossum
left a comment
There was a problem hiding this comment.
LGTM! I'm sorry for sitting on this for so long, I needed a break from reasoning about locks for a while. There are some comment nits, I'll push a cleanup commit to your branch.
Then I'll wait until the second business day in the new year before merging, in case you have more improvements yourself.
gvanrossum
left a comment
There was a problem hiding this comment.
Eh, I'm just editing on GitHub, creating a remote I can push to is too complicated. :-)
|
You also need a news blurb. I have nothing else I feel compelled to fix (I am giving up on capitalizing your sentences and re-flowing comments. :-) Once you push the blurb I'll assume you're ready for this to land (unless you let me know otherwise). Sorry again for the delay! |
|
And myself I have been busy. I'll make the changes you suggested on this weekend. Cheers! |
|
I did a pass on the comments. I'm freely admit that I have never paid much attention to having |
gvanrossum
left a comment
There was a problem hiding this comment.
Thanks! I'll merge it right away.
This is not something we should backport to 3.12 or 3.11, right?
Thanks!
I don't think so, I am guessing that people don't actually use Semaphores much, or that "message" attribute in the CancelledError. Only eccentrics like me doing weird stuff with asyncio. Btw, I now have "priority scheduling" in my asynkit project. That's the reason I started working with heapq objects and made a pr in that area recently. |
…t of asyncio.Condition.wait() (python#111694) Also fix a race condition in `asyncio.Semaphore.acquire()` when cancelled.
…t of asyncio.Condition.wait() (python#111694) Also fix a race condition in `asyncio.Semaphore.acquire()` when cancelled.
…t of asyncio.Condition.wait() (python#111694) Also fix a race condition in `asyncio.Semaphore.acquire()` when cancelled.
This PR addresses #111693 and adds unit tests for them.
Piggybacked on it are minor simplifying changes to
asyncio.Lock.acquire()andasyncio.Semaphore.acquire(), as wellas removal of dead code:
asyncio.CancelledError.asyncio.Future.