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.

Queue depth, and where put() waits

This explorer needs JavaScript: every time, depth and lag on it is computed in the page rather than downloaded as an image.

Overview

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 shape of almost every async program

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.

Producer and consumer, running together

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.

example_01.pyPython
Output
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 4

Read 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.

Backpressure is the maxsize

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.

Fanning out to several consumers

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.

Why a queue and not just gather

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.

A worker pool that shuts down cleanly

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.

example_02.pyPython
Output
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.

The other queue types, and the non-blocking calls

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 it

That 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.

Where it goes wrong

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.

Sources and next steps

Recall check

0 of 3

Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.

  1. What does this module say about “The shape of almost every async program”?

  2. What does this module say about “Producer and consumer, running together”?

  3. What does this module say about “Backpressure is the maxsize”?

Cheat sheet

Queues, Producers and Backpressure

Connect a fast producer to a slow consumer with an asyncio queue and watch one maxsize parameter prevent memory from growing without bound.

ASYNC PYTHON · vizlearn.in/async_python/queues_and_backpressure.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.