๐Ÿšง Concurrency Limits

Cap how many things run at once โ€” protect yourself and your downstreams. Unbounded concurrency is a self-inflicted DDoS.

The Problem: Unbounded Fan-Out Kills

Scenario: a request arrives that needs to enrich 10,000 records, each via a call to a downstream API. The naive async version is one line: await asyncio.gather(*(call(x) for x in records)). It fires all 10,000 requests essentially at once.

What happens: the downstream sees a 10,000-wide spike, blows past its own capacity, and starts returning 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))
The core insight: concurrency that isn't bounded is the bug. More in-flight work past the point of saturation doesn't add throughput โ€” it adds queueing, timeouts, and retries, which subtract throughput. The fastest system is often the one that refuses to start work it can't finish.

Concurrency Limit vs Rate Limit โ€” Not the Same Thing

These get conflated constantly, and they protect against different failures. This page is about concurrency.

Concurrency limitRate limit
BoundsN things in flight at onceN things per unit time
Unitsimultaneous requests (a count)requests / second (a rate)
Protectsmemory, pools, sockets, downstream saturationa quota / a per-second contract
Primitivesemaphore, sized pooltoken bucket, leaky bucket
They compose. "At most 50 in flight" (concurrency) and "at most 500/sec" (rate) are orthogonal walls โ€” a slow downstream can leave you under the rate limit yet pegged at the concurrency limit, and vice versa. This page covers concurrency; the per-second contract lives in rate limiting โ†’.

Sizing the Limit with Little's Law

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.
Rule of thumb: set your concurrency limit to the downstream's known safe throughput times its typical latency. Then load-test around it. The right limit is the smallest N that saturates the bottleneck without queueing in front of it.

How a Semaphore Gate Works

flowchart LR IN["10,000 tasks
arrive"] --> G{"๐Ÿšฆ Semaphore
N=50 slots"} G -->|"slot free"| RUN["running
(โ‰ค 50 at once)"] G -->|"all slots taken"| WAIT["โณ waiting
(the other 9,950)"] WAIT -.->|"a slot frees"| G RUN -->|"release slot"| DONE["done โœ”"] RUN -.->|"return slot"| G

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.

In Threading โ€” 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))
Prefer sizing the pool when the pool exists only to do this one kind of work โ€” one knob, no double-counting. Reach for a standalone 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").

In Asyncio โ€” 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))
Note the subtlety: 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.

In Multiprocessing โ€” 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
The pool bounds concurrency automatically: hand 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.

Where to Put the Limit

Match the limit to the actual bottleneck โ€” capping the wrong resource does nothing useful.

BottleneckSensible limitPrimitive
CPU-bound workโ‰ˆ number of coresPool(os.cpu_count()) / sized executor
A downstream APIits safe throughput ร— latency (Little's Law)Semaphore(N)
A databaseโ‰ค pool size โ‰ค server max_connectionsconnection pool (implicit)
Memory per taskRAM รท per-task footprintbounded queue + N workers
Blocking I/O overlapenough to hide latency, not exhaust FDssized thread pool / semaphore
Your connection pool is already a concurrency limit. An 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.

Key Takeaways

ModelCap concurrency withNatural ceiling
Threadingthreading.Semaphore(N) / ThreadPoolExecutor(max_workers=N)enough to hide I/O, not exhaust FDs
Asyncioasyncio.Semaphore(N) around each taskLittle's Law; watch eager Task creation
MultiprocessingPool(processes=N)โ‰ˆ CPU core count
Any (implicit)connection / DB pool sizeserver max_connections
Checklist: โ‘  every fan-out is bounded โ€” no naive full 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.