Python Module 15 – Async programming with asyncio
Queues, semaphores and rate limits in asyncio
Cap concurrent requests with asyncio.Semaphore, connect producers and workers with asyncio.Queue, shut queues down cleanly and limit requests per second.
What you will learn
- Limit concurrency with asyncio.Semaphore
- Build producer-consumer pipelines with asyncio.Queue and shut them down cleanly
- Apply back-pressure with a bounded queue
- Implement a token bucket that limits requests per second
- Distinguish a concurrency limit from a rate limit
Before you start
What you’ll learn · Before you start
What you will learn
- Limit concurrency with asyncio.Semaphore
- Build producer-consumer pipelines with asyncio.Queue and shut them down cleanly
- Apply back-pressure with a bounded queue
- Implement a token bucket that limits requests per second
- Distinguish a concurrency limit from a rate limit
Before you start
On this page
Twelve downloads started at once can swamp a server that accepts three at a time, and a producer that finds work
faster than its workers finish it fills memory. asyncio gives you three ways to keep concurrent work within bounds.
An asyncio.Semaphore(n) lets at most n tasks into a block at the same time, which is the standard way to cap
concurrent requests to one service. An asyncio.Queue connects producers and consumers: producers put() work items,
a fixed number of worker tasks get() them, and a queue with a maxsize makes a fast producer wait, which is called
back-pressure: Slowing a producer down when its consumers fall behind, instead of letting work pile up in memory. A bounded asyncio.Queue does it: put() waits while the queue already holds maxsize items..
A token bucket: A rate limit in which each request spends a token and tokens come back at a fixed rate, so requests start no faster than that rate however many tasks are asking. limits how many requests start per second, however many tasks there are:
each request spends a token, and tokens come back at a fixed rate. Semaphores bound how much runs at once, queues bound
how much work waits, and rate limits bound how fast work starts. All of them coordinate the tasks of one event loop
and are not thread-safe. Since Python 3.13, Queue.shutdown() tells waiting workers that no more work is coming.
A semaphore lets n tasks in at a time, and the rest wait cheaply
The program counts the downloads in flight and times the batch:
"""asyncio.Semaphore(3) lets three downloads run at a time; the other nine wait their turn."""
import asyncio
import time
in_flight = 0
most = 0
async def download(gate, n):
global in_flight, most
async with gate: # waits here while three downloads are running
in_flight += 1
most = max(most, in_flight)
await asyncio.sleep(0.05) # stands in for the transfer
in_flight -= 1
return f"file-{n:02}"
async def main():
gate = asyncio.Semaphore(3)
start = time.perf_counter()
files = await asyncio.gather(*(download(gate, n) for n in range(1, 13)))
took = time.perf_counter() - start
print(f"{len(files)} files, at most {most} downloads at once, results in order: {files[:3]} ...")
print(f"took about {took:.1f} s: 12 files in waves of 3, 0.05 s each, is 0.2 s")
asyncio.run(main()) Output
12 files, at most 3 downloads at once, results in order: ['file-01', 'file-02', 'file-03'] ... took about 0.2 s: 12 files in waves of 3, 0.05 s each, is 0.2 s
This output changes from run to run: the time is measured on each run, and the browser's event loop is slower
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 semaphore_cap.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Step through the run on a clock:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Twelve tasks start. gather() starts all twelve downloads at once. Each one reaches async with gate before it may download.
- Three get in. The semaphore has three permits, so file-01, file-02 and file-03 take one each and start. The other nine wait at async with gate, which costs no thread and blocks nothing.
- 0.05 s, the first wave ends. The three transfers finish after 0.05 s, and leaving the block gives each permit back, also for a download that raised.
- The second wave. In this run the next three waiting downloads, file-04 to file-06, take the permits at once. At no moment do more than three run.
- Waves three and four. The second wave ends at 0.10 s. The third runs from 0.10 to 0.15 s and the fourth from 0.15 to 0.20 s.
- 0.2 s, all twelve. Twelve files in waves of three, 0.05 s each, take 4 × 0.05 = 0.2 s. gather() returns the names in the order the downloads were given, and the most that ran at once was 3.
async with gate: took one of the semaphore’s three places, and the block gave it back at its end, also when the
download raised. The other downloads waited at async with gate: without blocking anything, so the twelve ran in
four waves of three. Waiting is cheap, so it is fine to start all twelve tasks at once and let the semaphore pace
them. Semaphores fit any shared limit: connections to one database, calls to one partner API, files open at once.
Take the number from the other side’s limit, such as the connections a database allows your service or the parallel
requests a partner documents, not from a guess.
Key idea
A semaphore caps how many tasks are inside its block at once, and the others wait at async with without a thread,
so starting every task at once is fine.
Checkpoint
Check: A semaphore caps calls at once
A bounded queue makes a fast producer wait for its workers
Queues decouple the code that finds work from the code that does it. Here a producer puts eight orders into a queue that holds at most three, and two workers take them out:
A producer, a bounded queue and two workers
Text description of the diagram
The diagram runs from top to bottom.
- The producer puts orders into the queue with await queue.put(order).
- The queue holds at most 3 orders. While it is full, put() waits, so the producer can never get far ahead of the workers.
- Two workers each take the next order with await queue.get() and handle it.
- When the producer has no more orders, it calls queue.shutdown(). The workers finish the orders still in the queue, and once it is empty, get() raises QueueShutDown and each worker stops.
"""A producer and two workers joined by a bounded asyncio.Queue; shutdown() (Python 3.13+) tells the workers that
no more work is coming."""
import asyncio
log = []
async def producer(queue):
for n in range(1, 9):
await queue.put(f"order-{n}") # waits while the queue already holds 3 orders
log.append(f"put order-{n} (queue holds {queue.qsize()})")
queue.shutdown() # no more orders: workers finish what is queued, then stop
log.append("producer: shut the queue down")
async def worker(name, queue, done):
while True:
try:
order = await queue.get()
except asyncio.QueueShutDown:
log.append(f"{name}: queue shut down and empty, stopping")
return
await asyncio.sleep(0.01) # stands in for packing the order
done.append((name, order))
queue.task_done()
async def main():
queue = asyncio.Queue(maxsize=3)
done = []
async with asyncio.TaskGroup() as tg:
tg.create_task(producer(queue))
tg.create_task(worker("worker-1", queue, done))
tg.create_task(worker("worker-2", queue, done))
print("\n".join(log))
print("orders packed:", len(done), "| by worker-1:", sum(1 for w, _ in done if w == "worker-1"))
asyncio.run(main()) Output
put order-1 (queue holds 1) put order-2 (queue holds 2) put order-3 (queue holds 3) put order-4 (queue holds 2) put order-5 (queue holds 3) put order-6 (queue holds 2) put order-7 (queue holds 3) put order-8 (queue holds 2) producer: shut the queue down worker-1: queue shut down and empty, stopping worker-2: queue shut down and empty, stopping orders packed: 8 | by worker-1: 4
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 queue_workers.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Whenever the queue was full, as at the fourth order, await queue.put() waited until a worker had taken one, so the
queue never held more than three orders. The two workers took orders with await queue.get() and called
task_done() after each. When the producer had no more orders it called queue.shutdown(). The workers still packed
everything in the queue, and once it was empty, get() raised asyncio.QueueShutDown and each worker returned. The
task group then saw all three tasks finish. Step through the same run on a clock:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Three orders fit. The producer runs first. put() returns at once for order-1, order-2 and order-3, and the queue is full, at its maxsize of 3.
- The fourth put() waits. put() of order-4 finds the queue full and waits. The producer can never get more than three orders ahead of the workers.
- worker-1 takes order-1. worker-1 takes order-1 with get(), which also wakes the waiting producer, and starts packing it, which takes 0.01 s.
- worker-2 takes order-2. worker-2 takes order-2 in the same way, and the queue holds only order-3.
- The producer catches up. The producer puts order-4 and order-5, so the log says the queue holds 2 and then 3. Its put() of order-6 waits again.
- The same rhythm. At 0.01 s the workers finish order-1 and order-2, call task_done() and take order-3 and order-4, and the producer puts order-6 and order-7. At 0.02 s they take order-5 and order-6, and the producer puts order-8.
- No more orders. With all eight orders put, the producer calls queue.shutdown(). order-7 and order-8 are still in the queue, and shutdown() leaves them there.
- The last two are packed. At 0.03 s the workers take order-7 and order-8 and pack them. Shutting the queue down did not throw them away.
- get() raises QueueShutDown. At 0.04 s each worker's next get() finds the queue empty and shut down and raises QueueShutDown, so both workers return. worker-1 packed four of the eight orders, and the task group sees all three tasks finish.
Version note
Queue.shutdown() and QueueShutDown are new in Python 3.13. Before that, the same stop needed a sentinel value,
such as one None per worker, or cancelling the workers.
Checkpoint
Check: A bounded queue between stages
Semaphore, queue or token bucket?
Each of the three bounds something else:
| Criterion | Semaphore | Queue with workers | Token bucket |
|---|---|---|---|
| What it limits | how many run at the same moment | how many items wait, and how many workers run | how many start per second |
| Typical use | calls to one service, open connections | pipelines: crawl, parse, store | an API that allows N requests a minute |
| Fast service, many tasks | many requests a second, a few at a time | as fast as the workers go | never more than the rate |
| Built in | yes | yes | no: a few lines of your own |
| When to choose | Protect a service from too many parallel calls | Feed work through stages at a steady size | Keep to a requests-per-second rule |
asyncio has no rate limiter of its own, so rate limits in asyncio are a few lines of your own, such as a token
bucket, or part of the client library you use. A rate limit and a concurrency limit answer different rules. A service that
answers in 30 milliseconds lets even one request at a time reach about 33 requests a second, so a semaphore alone
cannot keep a job under “100 requests a minute”. When a server says you went too fast, it answers with the HTTP status
429 Too Many Requests, often with a Retry-After header that says how long to wait. Rate limits in asyncio code
should respect that header rather than retry at once.
Checkpoint
Check: Choosing the limit for the rule
A lock, an event and four more tools cover the rest
asyncio has a few more tools for coordinating tasks, each made for one job:
| Tool | What it does |
|---|---|
asyncio.Lock |
Lets one task at a time into a block |
asyncio.Event |
Wakes every task that waits for a signal |
asyncio.Condition |
Combines a lock with waiting for a state to change |
asyncio.BoundedSemaphore |
Raises an error when it is released more often than acquired |
PriorityQueue and LifoQueue |
Take items out by priority, or newest first |
queue.join() |
Waits until task_done() has been called once for every item put in |
Reach for a lock when two tasks must not interleave inside one block, and for an event when many tasks wait for one moment, such as a configuration that has finished loading.
Checkpoint
Check: Locks, events and other primitives
Check yourself
One question at a time on semaphores, bounded queues, choosing a limit and the other primitives, the weakest idea first.
Practice that adapts to you
Practise this lesson
9 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Producer-consumer pipelines in depth: back-pressure, failures and a crawler Pro: opens with a Pro, Premium or Ultimate pass
A queue between stages decides how much memory a pipeline uses and how it fails. Below, back-pressure is measured, a worker that dies is reproduced with its three outcomes, and a crawler is built with a seen-set and a clean stop.
In this part:
- Back-pressure measured: 200 items with and without maxsize
- A worker that dies, reproduced: join() waits for ever
- A crawler with three workers, a seen-set and a clean stop
3 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Rate limits with numbers: token buckets, bursts and 429 responses Pro: opens with a Pro, Premium or Ultimate pass
A rate limit is arithmetic over time, so it is easy to get wrong by a factor of ten. Below, a token bucket is worked through on a fake clock, compared with a semaphore on the same requests, and taught to obey a server’s 429 answers.
In this part:
- A token bucket worked through on a fake clock
- Concurrency limit or rate limit? The same 30 requests both ways
- Obeying 429 Too Many Requests and Retry-After
- Exercise: crawl a site with a few workers, each page once
- Crawl a site with a few workers, each page once
- Results of the sample tests
1 diagram · 3 runnable examples (Python) · 1 exercise with sample tests · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Mid to senior
A pipeline's
await queue.join()never returns in production, although every worker looks idle. List the likely causes, and change the code so that this failure is quick and loud instead of silent. -
Senior to staff
Design an asyncio crawler for 1 million URLs on 5,000 domains, with at most 1 request a second per domain and at most 200 connections in total. Estimate the run time and describe the queues, the limits and the failure handling.
-
Mid to senior
A partner API allows 100 requests a minute with bursts of 20, and your job has 6,000 calls to make. Estimate the run time, design the limiter, and say what the client does when the API answers 429 anyway.
Key takeaways
asyncio.Semaphore(n)lets at mostntasks into a block at once; waiting at it is cheap.asyncio.Queueconnects producers and workers;maxsizemakes fast producers wait, which is back-pressure.- Since Python 3.13,
Queue.shutdown()lets workers finish what is queued and then raisesQueueShutDowninget(). - A semaphore limits concurrency, a token bucket limits requests per second; they answer different rules.
- asyncio’s queues and synchronization primitives work within one event loop and are not thread-safe.
Terms in this lesson
- back-pressure
- Slowing a producer down when its consumers fall behind, instead of letting work pile up in memory. A bounded asyncio.Queue does it: put() waits while the queue already holds maxsize items.
- token bucket
- A rate limit in which each request spends a token and tokens come back at a fixed rate, so requests start no faster than that rate however many tasks are asking.
References
- asyncio: Queues (Python Software Foundation)
- asyncio: Queue.shutdown() (Python Software Foundation)
- asyncio: Synchronization Primitives (Python Software Foundation)
- asyncio: Semaphore (Python Software Foundation)
- What's New In Python 3.13: asyncio (Queue.shutdown) (Python Software Foundation)
- RFC 6585: Additional HTTP Status Codes (429 Too Many Requests) (IETF)
- RFC 9110: HTTP Semantics, Retry-After (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress