Skip to content

GH-111693: Propagate correct asyncio.CancelledError instance out of asyncio.Condition.wait()#111694

Merged
gvanrossum merged 14 commits into
python:mainfrom
kristjanvalur:kristjan/CancelledError
Jan 8, 2024
Merged

GH-111693: Propagate correct asyncio.CancelledError instance out of asyncio.Condition.wait()#111694
gvanrossum merged 14 commits into
python:mainfrom
kristjanvalur:kristjan/CancelledError

Conversation

@kristjanvalur

@kristjanvalur kristjanvalur commented Nov 3, 2023

Copy link
Copy Markdown
Contributor

This PR addresses #111693 and adds unit tests for them.

Piggybacked on it are minor simplifying changes to asyncio.Lock.acquire() and asyncio.Semaphore.acquire(), as well
as removal of dead code:

  • code is documented
  • the exception handling code is made non-specific to asyncio.CancelledError.
  • dead code is removed from asyncio.Future.

@kristjanvalur kristjanvalur changed the title GH-111693: Propagate correct CancelledError out of Condition.wait() GH-111693: Propagate correct asyncio.CancelledError instance out of asyncio.Condition.wait() Nov 3, 2023

@gvanrossum gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. :-)

Comment thread Lib/asyncio/futures.py
Comment thread Lib/asyncio/locks.py Outdated
Comment thread Lib/asyncio/locks.py Outdated
Comment thread Lib/asyncio/locks.py
Comment thread Lib/asyncio/locks.py Outdated
@kristjanvalur

Copy link
Copy Markdown
Contributor Author

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>
@gvanrossum

Copy link
Copy Markdown
Member

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 :)

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 gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't reviewed the tests yet. I will do that after we've reached agreement over what the main code should be.

Comment thread Lib/asyncio/locks.py Outdated
self._wake_up_first()
raise

self._waiters.remove(fut)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I guess it makes it simpler to reason about, good to know that nested blocks have no overhead anymore.

Comment thread Lib/asyncio/locks.py Outdated
except BaseException:
self._waiters.remove(fut)
if not self._locked:
# Error occurred after release() was called, must re-do release.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kristjanvalur kristjanvalur Nov 21, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. set self._locked=False
  2. 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:

  1. _wake_up_first() always wakes up the same Task, at the head of the queue.
  2. 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.
  3. 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:

  1. two waiters are cancelled. They still are in the self._waiters queue.
  2. first one wakes up. Removes itself from self._waiters. Decides it doesn't want the lock, sees that self._locked is 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.
  3. Second one wakes up. Removes itself from self._waiters. Decides it doesn't want the lock, seelf that self._locked is False, calls self._wake_up_first.
  4. 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.

Comment thread Lib/asyncio/locks.py Outdated
self._waiters.remove(fut)
except exceptions.CancelledError:
await fut
except BaseException:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):
   pass

but that is future music :)

Comment thread Lib/asyncio/locks.py
Comment thread Lib/asyncio/locks.py Outdated
Comment thread Lib/asyncio/locks.py Outdated
Comment thread Lib/asyncio/locks.py Outdated
self._waiters.remove(fut)
except exceptions.CancelledError:
if not fut.cancelled():
await fut

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing as before -- I actually like the nested try/finally phrasing better. Also, again, sneaky about catching all errors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, especially if it costs nothing.

Comment thread Lib/asyncio/locks.py
@kristjanvalur

Copy link
Copy Markdown
Contributor Author

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.

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 gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, you've convinced me. Now I have to review the tests for this case.

Comment thread Lib/asyncio/locks.py Outdated
self._waiters.remove(fut)
except exceptions.CancelledError:
await fut
except BaseException:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Lib/asyncio/locks.py Outdated
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. :-)

Comment thread Lib/asyncio/locks.py
Comment thread Lib/asyncio/locks.py Outdated
if not fut.done():
self._value -= 1
fut.set_result(True)
# assert fut.true() and not fut.cancelled()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ditto if it already had another result, or an exception.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests look good, with one nit.

Comment thread Lib/asyncio/locks.py Outdated
if not fut.done():
self._value -= 1
fut.set_result(True)
# assert fut.true() and not fut.cancelled()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ditto if it already had another result, or an exception.)

Comment thread Lib/test/test_asyncio/test_locks.py Outdated
Comment on lines +762 to +764
"""Test that a cancelled error, received when awaiting wakeup
will be re-raised un-modified.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gvanrossum

Copy link
Copy Markdown
Member

Just one more thing. There's no test for the fix to Semaphore.acquire() that adds fut.done() and to the condition.

@kristjanvalur

Copy link
Copy Markdown
Contributor Author

A bit busy at the office these past days, but I'll revisit this on the weekend. Thanks for your review already.

@kristjanvalur

Copy link
Copy Markdown
Contributor Author

Some of these comments are anachronistic, should I update them? I.e. replace "coroutine" with "Task"

"""Release a semaphore, incrementing the internal counter by one.

        When it was zero on entry and another coroutine is waiting for it to
        become larger than zero again, wake up that coroutine.
        """

@kristjanvalur

Copy link
Copy Markdown
Contributor Author

Just one more thing. There's no test for the fix to Semaphore.acquire() that adds fut.done() and to the condition.

I'm not sure how to add such a test. It is really re-affirming the assumption that the semaphore is always done, which it
always is in vanilla asyncio, because the task can only be awoken via the Future.

To test this case, I would have to do a coro.send_exception(). I do this in asynkit but it is rather convoluted to do that, particularly if the C implementation of the Task is running. But I guess I could enforce a Python Task, and extract the relevant code from asynkit.experimental.interrupt

@kristjanvalur

Copy link
Copy Markdown
Contributor Author

I've modified the Semaphore.acquire() a little bit, using a Finally to wake up tasks that can be woken up. Added
a test, since previously "acquire()" would wake up only one such Task, but it now wakes up all tasks that are allowed to wake up.

@gvanrossum gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Lib/test/test_asyncio/test_locks.py Outdated

@gvanrossum gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, I'm just editing on GitHub, creating a remote I can push to is too complicated. :-)

Comment thread Lib/test/test_asyncio/test_locks.py Outdated
@gvanrossum

Copy link
Copy Markdown
Member

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!

@kristjanvalur

Copy link
Copy Markdown
Contributor Author

And myself I have been busy. I'll make the changes you suggested on this weekend. Cheers!

@kristjanvalur

kristjanvalur commented Jan 7, 2024

Copy link
Copy Markdown
Contributor Author

I did a pass on the comments. I'm freely admit that I have never paid much attention to having
them correctly capitalized or punctuated :)

@gvanrossum gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I'll merge it right away.

This is not something we should backport to 3.12 or 3.11, right?

@gvanrossum
gvanrossum merged commit 5216178 into python:main Jan 8, 2024
@kristjanvalur

kristjanvalur commented Jan 9, 2024

Copy link
Copy Markdown
Contributor Author

Thanks! I'll merge it right away.

Thanks!

This is not something we should backport to 3.12 or 3.11, right?

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.

@kristjanvalur
kristjanvalur deleted the kristjan/CancelledError branch January 9, 2024 10:40
kulikjak pushed a commit to kulikjak/cpython that referenced this pull request Jan 22, 2024
…t of asyncio.Condition.wait() (python#111694)

Also fix a race condition in `asyncio.Semaphore.acquire()` when cancelled.
aisk pushed a commit to aisk/cpython that referenced this pull request Feb 11, 2024
…t of asyncio.Condition.wait() (python#111694)

Also fix a race condition in `asyncio.Semaphore.acquire()` when cancelled.
Glyphack pushed a commit to Glyphack/cpython that referenced this pull request Sep 2, 2024
…t of asyncio.Condition.wait() (python#111694)

Also fix a race condition in `asyncio.Semaphore.acquire()` when cancelled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants