Queues, Producers and Backpressure
The pattern most real async programs are built from, and the one number that stops a fast producer from burying a slow consumer.
The pattern most real async programs are built from, and the one number that stops a fast producer from burying a slow consumer.
This explorer needs JavaScript: every time, depth and lag on it is computed in the page rather than downloaded as an image.
An asyncio.Queue is the piece that connects the two. Producers put items in, consumers get them out, and because both operations are awaitable, the queue is also the place where one side can wait for the other without either blocking the loop.
Once a program does more than a single fan-out, it tends to settle into the same structure: something produces work, something else consumes it, and the two run at different speeds.
The one number that separates a controlled program from one that slowly runs out of memory is maxsize. This page shows what happens with and without it, how join() and task_done() signal completion, and how to scale up to multiple consumers without the shutdown race that most first attempts contain.
These editors run inside a browser event loop that is already going, so the examples finish with await main(). In a standalone .py script — run with python file.py — you write asyncio.run(main()) instead. This starts a loop, runs the coroutine, and closes it. The two are the same program with different entry points.
produced 0 (queue holds 1)
produced 1 (queue holds 2)
produced 2 (queue holds 2)
consumed 0
produced 3 (queue holds 2)
consumed 1
produced 4 (queue holds 2)
consumed 2
consumed 3
consumed 4Read the queue-size column. It climbs to 2 and then stops. The producer put items 0 and 1 at once, and when it tried to put item 2 the queue was full, so await q.put(2) suspended the producer until the consumer took one out. From then on the producer only advances when the consumer makes room, which is why "produced 2" appears interleaved with "consumed" lines rather than all the production happening first.
That pacing is the whole point, and it came from one argument.
maxsize=2 made the queue bounded, and a bounded queue is what creates backpressure. When it is full, put waits, so a fast producer is by itself slowed to the rate the consumer can keep up with. The producer never runs more than two items ahead.
Remove the bound — asyncio.Queue() with no maxsize — and the behaviour changes completely. The producer dumps all five items in instantly, the queue holds all of them, and only then does the consumer start draining. With five items that is harmless. With a producer reading a million-line file into an unbounded queue faster than the consumer can process it, the queue grows without limit and the program runs out of memory. The failure looks like a leak and is really a missing bound.
So the size of the queue is not a tuning detail; it is the contract between the two sides. Bounded means "the producer may run at most this far ahead", and that sentence is usually what you actually want.
One slow consumer is often the bottleneck, and the queue makes it trivial to add more: start several consumer coroutines on the same queue and each takes the next available item. The queue hands each item to exactly one consumer, so work is shared without any coordination between them.
The clean way to know when everything is finished is task_done and join. Each consumer calls q.task_done() after handling an item; the producer, after putting everything, calls await q.join(), which returns only once every item has been marked done. That removes the awkward sentinel bookkeeping and gives a single, reliable "all work drained" signal — the standard way to shut a worker pool down cleanly.
gather and TaskGroup run a *fixed, known* set of coroutines at once. A queue is for the other case: an *open-ended stream* of work whose size you do not know in advance, arriving over time, to be processed by a bounded number of workers. Crawlers, pipelines, and anything that consumes a feed have that shape, and the queue is what decouples "how fast work arrives" from "how fast we handle it". The bound is the safety valve between the two.
The section above describes task_done and join. This is what they look like in the pattern you will actually write. Three workers share one bounded queue, the producer waits for the queue to drain, and then the workers are cancelled.
items handled: 9
per worker: {'w0': 3, 'w1': 3, 'w2': 3}Nine items, three each, with no coordination between the workers — the queue hands each item to exactly one get, so the sharing is automatic. Four details in that code are the ones that matter.
task_done is in a finally. If the work raises and the call is skipped, join waits forever for an item that has been taken but never acknowledged, and the program hangs at the end with no error. Putting it in finally is not tidiness, it is what makes join trustworthy.
join replaces the sentinel. The earlier example pushed None to tell the consumer to stop, which needs one sentinel per consumer and gets fiddly with several. join asks the queue directly whether everything has been handled.
The workers are cancelled, not asked to stop. A while True worker has no exit condition, and it does not need one: once join returns, the work is provably finished, so cancelling is correct rather than abrupt.
And gather(..., return_exceptions=True) collects the cancellations. Without it the CancelledErrors surface as "task exception was never retrieved" noise on shutdown.
asyncio.Queue has two siblings that change only the order items come out in. LifoQueue returns the most recent first, which turns a crawler's frontier from breadth-first into depth-first with no other change. PriorityQueue returns the smallest first and expects comparable items, so the usual shape is await q.put((priority, payload)). A tuple whose second element is not comparable will raise when two priorities tie, so an incrementing counter is often wedged in between.
Both put and get also have non-waiting forms. put_nowait raises asyncio.QueueFull rather than suspending, and get_nowait raises asyncio.QueueEmpty. They exist for the case where waiting is the wrong behaviour. A metrics pipeline would rather drop a sample than slow the thing producing it, for instance.
try:
q.put_nowait(sample)
except asyncio.QueueFull:
dropped += 1 # shedding load on purpose, and counting itThat is a deliberate choice to trade data for latency, and the counter is what keeps it honest. It is worth contrasting with the default: await q.put(...) propagates the pressure back to the producer, while put_nowait with a QueueFull handler absorbs it by discarding. Both are legitimate and they are opposites, so the decision belongs in the code with a comment rather than in whichever call you typed first.
An unbounded queue with a fast producer. It grows until memory runs out. Set maxsize to whatever "too far ahead" means for your program.
Forgetting the shutdown signal. Consumers looping on await q.get() wait forever once production stops, unless a sentinel or join tells them to stop. A program that hangs at the end is usually this.
Doing the slow work between get and task_done in a way that blocks the loop. The queue paces correctly, but a synchronous call in the consumer still freezes everything — see the blocking call.
Reading qsize and acting on it. By the time you branch on q.qsize(), another coroutine may have changed it. Let put and get do the waiting; the count is for observing, not for control.
Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.
What does this module say about “The shape of almost every async program”?
What does this module say about “Producer and consumer, running together”?
What does this module say about “Backpressure is the maxsize”?
Connect a fast producer to a slow consumer with an asyncio queue and watch one maxsize parameter prevent memory from growing without bound.