Cap how many things run at once โ protect yourself and your downstreams. Unbounded concurrency is a self-inflicted DDoS.
await asyncio.gather(*(call(x) for x in
records)). It fires all 10,000 requests essentially at once.
429 Too Many Requests and timeouts. Your client retries the failures
โ adding more load to an already-drowning dependency. Latency for everyone explodes, connection
pools exhaust, and the whole chain tips into congestion collapse: 100% busy, ~0% useful
work done.
# โ Anti-pattern: unbounded fan-out โ 10,000 concurrent calls at once
import asyncio, httpx
async def enrich_all(records, client):
# fires ALL 10k requests immediately: DDoSes the downstream, exhausts the pool
return await asyncio.gather(*(client.get(f"/enrich/{r}") for r in records))
These get conflated constantly, and they protect against different failures. This page is about concurrency.
| Concurrency limit | Rate limit | |
|---|---|---|
| Bounds | N things in flight at once | N things per unit time |
| Unit | simultaneous requests (a count) | requests / second (a rate) |
| Protects | memory, pools, sockets, downstream saturation | a quota / a per-second contract |
| Primitive | semaphore, sized pool | token bucket, leaky bucket |
Don't pick the number by vibes. Little's Law gives you the right ceiling from measurements you already have.
# Little's Law: concurrency = throughput ร latency
#
# L (in-flight) = ฮป (arrival/target rate) ร W (avg service latency)
#
# Example: downstream comfortably serves 2,000 req/s, each call averages 50ms:
# L = 2000 req/s ร 0.050 s = 100 concurrent requests
#
# โ a semaphore / pool of ~100 keeps the downstream at its sweet spot.
# Go higher and you just build a queue (latency โ, throughput flat).
# Go lower and you leave throughput on the table.
A semaphore is just a counter of N permits. Acquire to enter, release to leave; when all
permits are out, the next arrival waits. That's the entire mechanism โ a gate that lets at most
N through at a time.
Semaphore(N) and sized pools
Two ways to cap threads: wrap the critical section in a threading.Semaphore, or โ simpler
and usually better โ just size the ThreadPoolExecutor. The pool's max_workers
is a concurrency limit.
import threading, requests
from concurrent.futures import ThreadPoolExecutor
session = requests.Session()
sem = threading.Semaphore(50) # at most 50 downstream calls in flight
def call(record):
with sem: # blocks the thread when 50 are already out
return session.get(f"https://api/enrich/{record}", timeout=5).json()
# Even without the semaphore, the pool size caps concurrency at 50 by itself:
with ThreadPoolExecutor(max_workers=50) as pool:
results = list(pool.map(call, records))
Semaphore when many differently-sized
pools/threads must share one budget against a single downstream (e.g. "all workers combined:
max 50 to the DB").
Semaphore(N) around each task
This is the canonical async pattern, and the fix for the 10k-gather problem above: still
gather everything, but make every coroutine acquire a shared semaphore first, so only N run
concurrently.
import asyncio, httpx
async def enrich_all(records):
sem = asyncio.Semaphore(50) # โ
at most 50 in flight
async with httpx.AsyncClient(timeout=5) as client:
async def call(record):
async with sem: # gate: 51st coroutine awaits here
r = await client.get(f"/enrich/{record}")
return r.json()
return await asyncio.gather(*(call(r) for r in records))
# vs the โ naive version that fires all 10,000 at once and DDoSes the downstream:
# return await asyncio.gather(*(client.get(f"/enrich/{r}") for r in records))
gather still creates 10,000 Task objects eagerly (live
memory), the semaphore only bounds how many run. For truly massive streams, feed a bounded
asyncio.Queue with a fixed pool of N worker tasks instead โ that bounds both scheduling and
execution. Python 3.11+ asyncio.TaskGroup + a semaphore is the modern spelling. See
backpressure for the queue-based variant.
Pool(processes=N)
For CPU-bound fan-out, the concurrency limit is simply the process count. Because of the GIL, real
parallelism tops out at your core count anyway โ so N = os.cpu_count() is usually the
right ceiling. More processes than cores just adds context-switching and memory, not speed.
import os
from multiprocessing import Pool
def crunch(chunk):
return heavy_cpu_work(chunk) # e.g. hashing, parsing, numeric work
if __name__ == "__main__":
n = os.cpu_count() # cap parallelism at the core count
with Pool(processes=n) as pool: # โ
Pool IS the concurrency limit
results = pool.map(crunch, chunks)
# chunks beyond `n` queue internally and start as workers free up
Pool.map a million items
and only N run at a time โ the rest wait in the pool's internal queue. You never
materialize a million live workers. This is the multiprocessing equivalent of the async semaphore.
Match the limit to the actual bottleneck โ capping the wrong resource does nothing useful.
| Bottleneck | Sensible limit | Primitive |
|---|---|---|
| CPU-bound work | โ number of cores | Pool(os.cpu_count()) / sized executor |
| A downstream API | its safe throughput ร latency (Little's Law) | Semaphore(N) |
| A database | โค pool size โค server max_connections | connection pool (implicit) |
| Memory per task | RAM รท per-task footprint | bounded queue + N workers |
| Blocking I/O overlap | enough to hide latency, not exhaust FDs | sized thread pool / semaphore |
httpx.Limits(max_connections=100)
or a SQLAlchemy pool_size=20 caps in-flight work implicitly โ the 101st request blocks
waiting for a connection. Often you don't need a separate semaphore; sizing the pool correctly
already imposes the ceiling. See connection pooling.
| Model | Cap concurrency with | Natural ceiling |
|---|---|---|
| Threading | threading.Semaphore(N) / ThreadPoolExecutor(max_workers=N) | enough to hide I/O, not exhaust FDs |
| Asyncio | asyncio.Semaphore(N) around each task | Little's Law; watch eager Task creation |
| Multiprocessing | Pool(processes=N) | โ CPU core count |
| Any (implicit) | connection / DB pool size | server max_connections |
gather โก size N
with Little's Law, then load-test โข cap the resource that's actually the bottleneck โฃ your pool size is
already a limit โ don't double-count โค concurrency (count) and rate
(per-second) are separate walls; you often need both.