An unbounded queue.Queue has grown a video-metadata extractor to a 2.4 GB working set: how does a maxsize bound fix it, and where does the pressure move?
answer
- Memory standing in for a missing wait
- The default constructor sets no ceiling
- A ceiling makes the fast side wait
- Backpressure relocates the problem upstream
- It exposes the deficit, not fixes it
basics
~20 sAn unbounded queue absorbs the gap between a fast producer and slow consumers as memory. A maxsize makes put block once the buffer is full, so the producer runs at consumer speed and the backlog stops growing. The pressure moves upstream.
solid answer
~50 sThe queue is doing exactly what an unbounded buffer does: converting a throughput deficit into resident memory. Confirm it first — log `qsize()` over time and watch it climb monotonically while consumers stay busy — then construct the queue with a `maxsize` sized from the per-item cost, so `put()` blocks once the buffer is full and the producer is paced by the consumers. Two consequences to state out loud. First, the pressure does not disappear, it moves: whatever feeds the producer must now tolerate a producer that stalls, so make sure the blocked thread holds no lock and is not also the only thing draining the queue. Second, a bound does not create throughput — it makes the deficit visible instead of silent, so pair it with `put(timeout=...)` and a metric, and decide deliberately whether to wait, shed or spill.
code
python · 29 linesimport queue
import threading
paths = queue.Queue(maxsize=32) # bounded: producer waits, memory stays flat
extracted = []
def consumer():
while True:
try:
path = paths.get()
except queue.ShutDown: # 3.13+: no per-worker sentinel needed
return
try:
extracted.append(path.upper())
finally:
paths.task_done()
threads = [threading.Thread(target=consumer) for _ in range(4)]
for t in threads:
t.start()
for i in range(1000):
paths.put(f"clip-{i}.mkv") # blocks once 32 are outstanding
paths.join()
paths.shutdown() # wakes every blocked get with ShutDown
for t in threads:
t.join()
print(len(extracted), extracted[0])go deeper
Know that queue.Queue() with no argument is unbounded and that queue.Queue(maxsize=n) makes put() wait once n items are outstanding. That one argument is the difference between a growing backlog and a paced producer.
Explain the mechanism end to end: why an unbounded buffer turns a rate mismatch into resident memory, how a blocked put() paces the producer, and how to size the bound from the per-item cost rather than by feel.
Demonstrate the diagnosis — depth trend against memory — and then reason about the consequences: what upstream now waits, whether the blocked producer holds anything, how you shed or spill under queue.Full, and how the pipeline shuts down without leaving queued handles unclosed.
Own the policy across services: which pipelines are allowed to block, which must shed load and by what rule, what the standard queue-depth telemetry is, and how buffer footprints are accounted for in capacity plans rather than discovered from a memory alarm.
## Confirm the queue is the buffer that is growing A climbing working set has many causes, so start by proving this one. Log `queue.Queue.qsize()` on an interval alongside a memory reading. The signature of an unbounded-queue backlog is unmistakable: `qsize()` rises steadily and never recovers, the consumer threads are continuously busy rather than idle, and memory tracks the queue depth almost linearly. If `qsize()` oscillates around a stable value while memory climbs anyway, the queue is innocent and something else is retaining objects. At 2.4 GB with a queue of extracted-frame buffers, the arithmetic usually tells the story on its own: depth times average item size lands near the resident total. ## Why unbounded is the default and why that is a trap `queue.Queue()` and `queue.Queue(maxsize=0)` are unbounded. `put()` on them never blocks and never raises `queue.Full`, so a producer runs at its own maximum speed regardless of what the consumers can absorb. When the producer is faster — a directory walk enumerating files far quicker than each file can be probed — the difference accumulates in the buffer. Nothing errors. Nothing logs. Memory simply rises until the process is killed or starts paging. The damage is often worse than the byte count suggests, because queued items are frequently *handles* rather than plain data. If the producer opens each container file and puts the open file object on the queue, an unbounded queue keeps thousands of descriptors open at once; the consumer is the only thing that closes them, and a consumer that dies leaves every queued handle unclosed until interpreter exit. Bounding the queue bounds the live resources, not just the bytes. ## What maxsize actually does `queue.Queue(maxsize=64)` makes the not-full condition real. Once 64 items are outstanding, `put()` parks the producer thread on a condition variable until a consumer takes one. The producer now runs at exactly the consumers' aggregate rate, and the resident cost of the pipeline is bounded by `maxsize` times the per-item size plus whatever the consumers hold in flight. That is backpressure: the slow stage's rate is propagated backwards to the fast one through blocking. Choose the number from cost, not from taste. Work out the memory a single item retains, pick the buffer footprint you are willing to fund, and divide. The bound wants to be large enough to keep consumers from starving during a producer hiccup — a small multiple of the consumer count is a common starting point — and small enough that the footprint is one you can state in a review. ## Where the pressure moves This is the part interviewers are listening for. Blocking the producer does not delete the backlog; it relocates the decision. * **The producer must be safe to block.** A thread parked in `put()` is not running. If it holds a lock, that lock is held for the duration. If it is also the only thread that drains a second queue, or a worker that feeds the very queue it is blocked on, you have converted a memory problem into a hang. * **Something upstream now waits.** If the producer is reading a socket, a blocked producer stops reading and the transport's own flow control eventually pushes back on the sender — usually the outcome you want. If the producer is servicing user requests, a blocked producer is added latency, and you may prefer `put(item, timeout=0.2)` and an explicit decision on `queue.Full`: shed the item, spill it to disk, or return a busy response. * **The deficit becomes visible.** A bound does not add throughput. If consumers are permanently slower than the producer, a bounded queue means the producer is permanently blocked — which is the honest signal that you need more consumers, cheaper per-item work, or less input. Instrument it: count `queue.Full` events, or time how long `put()` waits, and alert on it. ## Shutting the pipeline down A bounded queue also changes shutdown. A producer blocked in `put()` cannot notice a stop flag, and a consumer blocked in `get()` cannot either. On 3.13+ `queue.Queue.shutdown()` unblocks both: every waiting `put()` and `get()` raises `queue.ShutDown`, and further `put()` calls are refused while `get()` keeps serving the remaining items until the buffer empties. `shutdown(immediate=True)` discards what is left and marks those items done, which also releases a waiting `join()`. Before 3.13 the equivalent is one sentinel item per consumer, which is awkward precisely because a full queue may have no room to accept the sentinels. Either way, ensure the consumers close what they hold on the way out, so a shutdown does not leave the queued handles unclosed.
- How would you choose the actual maxsize number rather than guessing?Derive it from the per-item retained size and the footprint you are willing to fund: measure one item, pick a memory budget for the buffer, divide. Then sanity-check the lower bound — the buffer should be deep enough that consumers do not starve during a normal producer hiccup, which usually means a small multiple of the consumer count. State the resulting footprint explicitly in review, because a `maxsize` is a memory decision written as an integer.
- What do you do when the producer thread cannot afford to block on put()?Make the decision explicit instead of letting it default to unbounded growth. Use `put(item, timeout=...)` and handle `queue.Full`: drop the item and count it, spill it to durable storage, downsample the stream, or signal the caller that the system is busy. Whichever you pick, emit a metric — silently dropping work is only defensible when someone can see it happening and knows the rate.
- Why should you still log queue.Queue.qsize() even though it is only approximate?Because approximate is fine for a trend and useless for control flow. Another thread can change the depth between the call and the next line, so `qsize()` must never gate a `get()`. As a periodic metric, though, its trajectory is the clearest early warning you have: a depth that rises and never returns to baseline says the consumers are losing, long before memory or latency alarms fire.
saying these in an interview costs you the question
- Raises the memory limit instead of bounding the queue
- Thinks a maxsize adds consumer throughput
- Blocks a producer that holds a lock while waiting
- Never instruments queue depth or Full events
- Assumes queued items hold no open resources
- Uses qsize() to gate puts instead of bounding the queue