CPython’s queue.py, Line by Line
The real bounded blocking queue from the standard library you already import. One mutex, three Conditions, and the not-empty / not-full invariants — walk the whole file top to bottom and watch the monitor block, wake, and drain.
Code walk · Languages & Runtimes. The source ↗
A free, interactive, animated visual explainer of CPython’s queue.py, Line by Line — built to be understood, not skimmed.
Questions
- How does Python’s queue.Queue work internally?
- It is a monitor around a collections.deque. All state lives behind a single mutex (threading.Lock), and three Condition variables share that one lock: not_empty, not_full, and all_tasks_done. put() appends to the deque and calls not_empty.notify(); get() pops and calls not_full.notify(). When the queue is full a putter waits on not_full; when it is empty a getter waits on not_empty. Because every method acquires the same mutex, only one thread ever mutates the deque at a time, which is what makes it thread-safe for many producers and many consumers.
- Why does queue.Queue use three Condition variables but only one lock?
- The single mutex gives mutual exclusion — one critical section protects the deque so put and get can never interleave and corrupt it. The three Conditions all wrap that same mutex; they exist only to wake the right waiter. Adding an item notifies not_empty (a blocked getter), removing an item notifies not_full (a blocked putter), and draining the unfinished-task count to zero notifies all_tasks_done (a blocked join). The source states it directly: the mutex "is shared between the three conditions, so acquiring and releasing the conditions also acquires and releases mutex." One lock for correctness; three Conditions for precise, non-thundering wakeups.
- What is the difference between notify() and notify_all() in queue.py?
- put() and get() call notify() — a single item added or a single slot freed can satisfy exactly one waiter, so waking one is enough and cheapest. task_done() calls notify_all() because when unfinished_tasks drops to zero, every thread blocked in join() must be released, not just one. So if two consumers are both parked in get() and a producer puts one item, notify() wakes exactly one of them; the other stays blocked until the next put.
- Why does put() use a while loop around wait() instead of an if?
- Because a wakeup does not guarantee the condition is true. A woken putter must re-acquire the mutex, and between the notify and that re-acquisition another thread could have refilled the queue; there are also spurious wakeups and the timeout path. The loop while self._qsize() >= self.maxsize: self.not_full.wait() re-tests the predicate on every wakeup, so the thread only proceeds to _put when there is genuinely a free slot. An if would skip that recheck and let a put exceed maxsize — the bound would silently break.
- What do task_done() and join() actually do?
- They implement "wait until all work is finished," separate from the queue being empty. Every put() increments unfinished_tasks; each consumer calls task_done() after processing an item, which decrements it and, when it hits zero, calls all_tasks_done.notify_all(). join() simply blocks in while self.unfinished_tasks: self.all_tasks_done.wait(). Calling task_done() more times than there were items raises ValueError("task_done() called too many times"). Note that an item being removed by get() is not the same as being done — join waits for the task_done, not the get.