Producer-consumer with asyncio.Queue

asyncio · Practical · 11 min

Queues give you bounded concurrency and back-pressure for crawlers, pipelines and job runners.

asyncio.Queue is a FIFO for coroutines (not thread-safe; use queue.Queue across threads). await q.put(item) waits if the queue is full (when created with maxsize), and await q.get() waits until an item is available. That waiting gives back-pressure: a fast producer slows down to match the consumers.

The standard pattern is a fixed pool of worker tasks:

  • start N workers that loop: item = await q.get(), process, q.task_done(),
  • the producer puts items,
  • await q.join() waits until every put item has been marked done.

Then the workers must be stopped. Before 3.13 you cancelled them or sent sentinel values. Python 3.13 adds `q.shutdown()`: after it, put() raises QueueShutDown, and get() raises QueueShutDown once the queue is empty, so workers can exit their loop cleanly. shutdown(immediate=True) also discards pending items.

PriorityQueue and LifoQueue are variants. Use the _nowait methods (put_nowait, get_nowait) when you are not allowed to wait, and handle QueueFull / QueueEmpty.

The number of workers is your concurrency limit, which is often clearer than a Semaphore.

Example

import asyncio

async def worker(name, q, results):
    while True:
        try:
            n = await q.get()
        except asyncio.QueueShutDown:      # 3.13+
            return
        await asyncio.sleep(0.01)
        results.append(n * n)
        q.task_done()

async def main():
    q, results = asyncio.Queue(maxsize=3), []
    async with asyncio.TaskGroup() as tg:
        for i in range(2):
            tg.create_task(worker(f"w{i}", q, results))
        for n in range(6):
            await q.put(n)                 # waits while the queue is full
        await q.join()                     # all items processed
        q.shutdown()                       # workers exit their loops
    print(sorted(results))                 # [0, 1, 4, 9, 16, 25]

asyncio.run(main())

Remember

  • put() waits when full, get() waits when empty: built-in back-pressure
  • Pair every get() with task_done() so join() can finish
  • 3.13's Queue.shutdown() stops workers without sentinels

Watch out for

  • Forgetting task_done(), so await q.join() hangs forever.

Check yourself

  1. What does await q.join() wait for?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/asyncio-queue.html

Builds on

Unlocks

Nothing builds on this yet.