Skip to content

gh-83055: Reserve freed asyncio.Queue slots for woken putters - #160967

Open
asvetlov wants to merge 1 commit into
python:mainfrom
asvetlov:claude/cpython-queue-fairness-6523a2
Open

asvetlov wants to merge 1 commit into
python:mainfrom
asvetlov:claude/cpython-queue-fairness-6523a2

Conversation

@asvetlov

Copy link
Copy Markdown
Contributor

When the queue is full, get_nowait() pops a waiting putter and wakes it, but the freed slot stays open until that putter resumes. A later put() (fast path) or put_nowait() can take it; the woken putter then finds the queue full and is re-appended to the end of _putters, so items are put out of order and a waiter can be starved.

This PR reserves the freed slot for the woken putter:

  • get_nowait() increments _reserved when it wakes a putter; full() counts reserved slots (qsize() + _reserved >= maxsize).
  • The woken putter releases its reservation right before put_nowait(); if it is cancelled after being woken it passes the reservation to the next putter.
  • shutdown() clears the counter.

This is the same idea asyncio.Semaphore uses for fairness (taking the value on the waiter's behalf). Compared to the earlier attempt (gh-112501):

  • no new exceptions; full() is False still guarantees put_nowait() succeeds;
  • O(1), no scanning of done futures;
  • unreserved free slots remain usable, so Serhiy's example (two get_nowait() with one waiting putter, then put_nowait()) succeeds.

Behaviour change: full() may be True while qsize() < maxsize, for as long as a woken putter has not resumed. Documented in full()/put().

Getters are left as is: the issue is about the order items enter the queue, and reserving items would change the meaning of empty().

🤖 Generated with Claude Code

When get_nowait() wakes a waiting putter, the freed slot was open until
the putter resumed, so a later put() or put_nowait() could take it.  The
woken putter then found the queue full again and went to the back of the
line, putting items out of order.

Count slots promised to woken putters in full(), so the slot stays with
the putter that was woken for it.  This keeps put_nowait() consistent with
full() and stays O(1), unlike the approach in pythongh-112501.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@read-the-docs-community

Copy link
Copy Markdown

Documentation build overview

📚 cpython-previews | 🛠️ Build #35522303 | 📁 Comparing 268b387 against main (6632294)

  🔍 Preview build  

2 files changed
± library/asyncio-queue.html
± whatsnew/changelog.html

@asvetlov
asvetlov marked this pull request as ready for review October 30, 2026 13:30
@asvetlov
asvetlov requested a review from willingc as a code owner October 30, 2026 13:31
@asvetlov

Copy link
Copy Markdown
Contributor Author

@YvesDup here is my try to make asyncio.Queue fair.

@YvesDup

YvesDup commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

@YvesDup here is my try to make asyncio.Queue fair.

Thank's a lot @asvetlov, I will have a look at begining of the next week.

@YvesDup

YvesDup commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@YvesDup here is my try to make asyncio.Queue fair.

That looks good. I'm starting to review the code
I closed my incomplete draft PR #160983 in favor of yours.

Comment thread Lib/asyncio/queues.py
if not self._is_shutdown:
# Take the slot reserved for us.
self._reserved -= 1
return self.put_nowait(item)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

self.put_nowait returns only None. return statement could be removed.

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.

Correct, but this line is not affected by the PR. I prefer to keep it untouched here

Comment thread Lib/asyncio/queues.py Resolved

@OsmnvAslan OsmnvAslan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for working on this! I tested it on a checkout merged with current main: test_queues passes, and the new tests fail on main as expected. I also ran a randomized put/get/cancel script (300 seeds) and a few edge cases (a woken putter that gets cancelled or closed via GeneratorExit, shutdown() and shutdown(immediate=True) with reserved slots). _reserved stayed consistent in all of them.

One compatibility concern: subclasses that override put() but inherit get_nowait(), or the other way round, and use _putters/_wakeup_next() directly. It's private API, but the pattern is fairly common: a GitHub code search for _wakeup_next(self._putters) outside queues.py gives more than 100 hits. For example, NVIDIA NeMo-Agent-Toolkit's AsyncIOProducerConsumerQueue overrides put() with the old loop and inherits get_nowait(). With this PR, a simple producer/consumer on such a subclass (maxsize=2) deadlocks after 3 items: the queue is empty, but full() stays True because _reserved == 2 is never released. In the opposite case, a subclass that wakes putters itself via _wakeup_next(self._putters) drives _reserved negative, and the queue accepts more than maxsize items.

Probably not a blocker, but it might be worth a What's New note (Porting section) along with the full()/qsize() change. If you want to harden the second case, tracking the woken futures in a set instead of a counter keeps _reserved from going negative and makes the _is_shutdown special-casing unnecessary; I tried it locally, and only the tests that compare _reserved with an int needed adjusting.

Nit: the full() docstring in queues.py still says "Return True if there are maxsize items in the queue".

await put_2
self.assertEqual(q._reserved, 0)

async def test_put_nowait_uses_unreserved_slots(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test makes me deeply uncomfortable. Because, IMO, for an asyncio.Queue to be fair, it should not be possible to have a successful put_nowait call with pending or/and woken putters regardless of queue's state.(full or not).
But maybe I have missed something.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants