When producers outrun consumers, bound the queue or die by OOM. A full queue is not a failure โ it's a signal to slow down.
SIGKILL. You didn't crash from a bug โ you crashed from
succeeding at accepting work you had no capacity to do.
# โ Anti-pattern: unbounded queue + fast producer + slow consumer = OOM
import queue, threading, time
q = queue.Queue() # maxsize=0 โ UNBOUNDED. This is the bug.
def producer():
for i in range(10_000_000):
q.put(fetch_item(i)) # never blocks โ memory grows without limit
def consumer():
while True:
item = q.get()
slow_process(item) # 10x slower than producer โ queue only grows
threading.Thread(target=consumer, daemon=True).start()
producer() # RSS climbs โ GC thrash โ ๐ฅ OOM kill. The queue ate all your RAM.
The fix is one word: bound the queue. Give it a maxsize. Now when the
consumer falls behind and the queue fills, the producer's put() can no longer succeed
instantly โ it must block, drop, or reject. That stall is backpressure: the
slow consumer's pain is transmitted back up the pipeline to the producer, and (if the producer is
itself serving a caller) all the way back to the client.
maxsize ร item_size โ a number you chose, not the whim of
traffic.
Once the queue is full, you must decide what a producer does. There is no free lunch โ pick deliberately.
| Policy | Behavior when full | Cost | Use when |
|---|---|---|---|
| Block | Producer waits on put() until a slot frees | Latency rises upstream; true end-to-end backpressure | Producer can slow down safely (batch jobs, pipelines) |
| Drop | Discard oldest (or newest) item, keep going | Data loss โ accept only if stale data is worthless | Metrics, live telemetry, video frames (freshness > completeness) |
| Reject / load-shed | Refuse immediately, return an error (HTTP 503) | Some requests fail fast, but the service stays alive | Request/response servers โ fail some to protect the rest |
queue.Queue(maxsize=N)
queue.Queue is thread-safe and, with a maxsize, gives you blocking
backpressure for free: put() blocks the producer thread when the queue is full.
import queue, threading, time
q = queue.Queue(maxsize=100) # โ
BOUNDED โ memory capped at ~100 items
def producer():
for i in range(1_000_000):
q.put(build_item(i)) # BLOCKS here when full โ producer is throttled
q.put(None) # sentinel: tell consumer to stop
def consumer():
while True:
item = q.get()
if item is None:
break
slow_process(item) # ~100ms each
q.task_done()
t = threading.Thread(target=consumer)
t.start()
producer() # naturally paced by the consumer's drain rate
t.join()
Prefer non-blocking policies? Use the _nowait variants to reject or drop instead of blocking:
# Reject policy (load-shed): fail fast instead of waiting
try:
q.put_nowait(item) # raises queue.Full immediately if no slot
except queue.Full:
metrics.increment("shed") # e.g. return HTTP 503 to the caller
# Drop-oldest policy: make room by discarding the stalest item
try:
q.put_nowait(item)
except queue.Full:
try:
q.get_nowait() # evict oldest
except queue.Empty:
pass
q.put_nowait(item) # then insert newest
put(timeout=...) is the pragmatic middle ground: block, but only
for so long. q.put(item, timeout=0.5) waits up to 500ms, then raises queue.Full
so you can shed instead of stalling a request thread forever.
asyncio.Queue(maxsize=N) + task caps
asyncio.Queue(maxsize=N) is the async analogue: await q.put(item) suspends
the producer coroutine when the queue is full instead of blocking a thread. Same backpressure, no OS
threads consumed.
import asyncio
async def producer(q: asyncio.Queue):
for i in range(1_000_000):
await q.put(build_item(i)) # SUSPENDS when full โ producer paced
await q.put(None)
async def consumer(q: asyncio.Queue):
while True:
item = await q.get()
if item is None:
break
await slow_process(item)
q.task_done()
async def main():
q = asyncio.Queue(maxsize=100) # โ
BOUNDED
await asyncio.gather(producer(q), consumer(q))
asyncio.run(main())
asyncio.gather(*(handle(x) for x in
millions)) creates a Task object per item eagerly โ each Task is live memory, and you'll OOM on
the tasks themselves before any queue helps. Bound the concurrency too.
# โ
Bound total in-flight coroutines with a semaphore (this is backpressure on scheduling)
import asyncio
sem = asyncio.Semaphore(100) # at most 100 coroutines past the gate at once
async def handle(item):
async with sem: # await here IS the backpressure
await slow_process(item)
async def main(items):
# still creates Task objects, but the semaphore keeps only 100 actually running.
# For truly huge streams, feed through a bounded asyncio.Queue + a fixed worker pool instead.
await asyncio.gather(*(handle(i) for i in items))
asyncio.Queue caps buffered
data, and a bounded worker pool (fixed number of consumer tasks pulling from that queue) caps live
coroutines. A semaphore is the quick version of the second knob. See
concurrency limits for the full pattern.
multiprocessing.Queue(maxsize=N)
Across processes you use multiprocessing.Queue(maxsize=N). It gives the same blocking
backpressure โ put() blocks the producer process when full โ but there are two nuances
that bite under load.
import multiprocessing as mp
import time
def producer(q: mp.Queue):
for i in range(1_000_000):
q.put(build_item(i)) # BLOCKS the producer process when full
q.put(None)
def consumer(q: mp.Queue):
while True:
item = q.get()
if item is None:
break
slow_process(item)
if __name__ == "__main__":
q = mp.Queue(maxsize=100) # โ
BOUNDED across the process boundary
c = mp.Process(target=consumer, args=(q,))
c.start()
producer(q)
c.join()
pickle.
Big or unpicklable objects (open sockets, DB connections, lambdas) either explode the cost of each
put() or fail outright. Pass small, plain data (ids, bytes, dataclasses) โ not live handles.
mp.Queue.put() doesn't write to the pipe
directly. It appends to an in-process buffer that a hidden feeder thread drains into the OS
pipe. So put() can return "immediately" while items sit in that buffer โ meaning
maxsize backpressure is slightly lazy, and a process can exit before the feeder has
flushed. Always drain the queue and join() before shutdown, or use
q.close(); q.join_thread().
Pool or JoinableQueue in practice. For most CPU
fan-out, mp.Pool(processes=N) already bounds concurrency (see
concurrency limits) and hides the queue plumbing. Reach for a raw
bounded mp.Queue only when you need an explicit producer/consumer topology.
A bounded queue in the middle of your service is useless if the layer in front of it happily accepts unlimited work. Backpressure has to propagate all the way to the source, or the OOM just moves one hop upstream.
listen() backlog and your
worker/connection cap are backpressure. When workers are all busy, new connections queue in the
kernel; when that backlog fills, the OS refuses SYNs and clients get connection errors โ the signal
to back off.maxsize โก a full queue blocks, drops, or
sheds โ never grows โข bound in-flight tasks, not just buffered items (asyncio) โฃ don't ack/commit
streaming work until it's done โค the accept queue / connection cap is your first backpressure valve.
Cap the memory on purpose, or the kernel will cap it for you.