Three real builds, start to finish. Each one: find the bottleneck, pick the model that fits it, then stack the collection's techniques until it holds under load.
Every earlier page taught one move in isolation. Real systems need a combination, and the combination is dictated by the bottleneck. Each case study runs the same loop from Optimizations 101: Measure โ find the bottleneck โ pick the model โ apply techniques โ protect it.
A fetch is ~200ms of waiting (DNS + TCP + TLS + server think time) and
~1ms of CPU (parse a small JSON body). That's ~99.5% I/O wait. The CPU
is idle; the constraint is how many requests we can have in flight at once without melting
sockets or memory. Classic I/O-bound.
concurrency = throughput ร latency. For 3,000 req/s
at 200ms each we need 3000 ร 0.2 = 600 requests in flight. 600 OS threads is
painful (stacks, context switches); 600 coroutines is trivial.
Threads would work but a thread per in-flight request costs ~8MB of stack and real context-switch overhead; scaling to thousands is wasteful. Multiprocessing gives CPU parallelism we don't need โ there's no CPU to parallelize. Asyncio holds tens of thousands of waiting sockets on one core for almost nothing. See the model comparison; for pushing async hard see async at scale.
AsyncClient for the whole run.Semaphore caps in-flight requests.Queue; producers block when the writer falls behind.import asyncio
import json
import random
from collections import defaultdict
from urllib.parse import urlsplit
import httpx
# --- tunables (size from Little's Law: throughput * latency) ---
GLOBAL_CONCURRENCY = 600 # ~3000 req/s * 0.2s
PER_HOST_CONCURRENCY = 4 # politeness: never flood one host
RESULT_QUEUE_MAX = 10_000 # backpressure bound on unwritten results
REQUEST_TIMEOUT = 10.0
MAX_RETRIES = 3
limits = httpx.Limits(max_connections=GLOBAL_CONCURRENCY,
max_keepalive_connections=200)
def url_source(path: str):
"""Stream URLs from disk โ never load 1M into a list (memory-bound trap)."""
with open(path) as fh:
for line in fh:
url = line.strip()
if url:
yield url
async def fetch_one(client: httpx.AsyncClient, url: str) -> dict | None:
"""One fetch with timeout + retry/backoff. Returns a record or None on give-up."""
for attempt in range(MAX_RETRIES):
try:
r = await client.get(url, timeout=REQUEST_TIMEOUT)
r.raise_for_status()
# tiny CPU cost โ fine to do inline; if it grew, offload (see async_at_scale)
return {"url": url, "status": r.status_code, "len": len(r.content)}
except (httpx.HTTPError, httpx.TimeoutException):
if attempt == MAX_RETRIES - 1:
return {"url": url, "error": True}
# exponential backoff + full jitter โ avoid retry stampedes
await asyncio.sleep((2 ** attempt) * 0.5 * (0.5 + random.random()))
return None
async def writer(queue: "asyncio.Queue[dict | None]", out_path: str) -> None:
"""Single consumer: the only thing that touches the file. Bounds memory."""
with open(out_path, "w") as fh:
while True:
record = await queue.get()
if record is None: # sentinel = done
queue.task_done()
return
fh.write(json.dumps(record) + "\n")
queue.task_done()
async def scrape(url_path: str, out_path: str) -> None:
global_sem = asyncio.Semaphore(GLOBAL_CONCURRENCY)
host_sems: dict[str, asyncio.Semaphore] = defaultdict(
lambda: asyncio.Semaphore(PER_HOST_CONCURRENCY)
)
results: asyncio.Queue = asyncio.Queue(maxsize=RESULT_QUEUE_MAX)
async with httpx.AsyncClient(limits=limits, follow_redirects=True) as client:
writer_task = asyncio.create_task(writer(results, out_path))
async def worker(url: str) -> None:
host = urlsplit(url).netloc
# two nested caps: global budget AND per-host politeness
async with global_sem, host_sems[host]:
record = await fetch_one(client, url)
if record is not None:
await results.put(record) # blocks here if writer is behind = backpressure
# Spawn in bounded waves so we never hold 1M Task objects in memory at once.
pending: set[asyncio.Task] = set()
for url in url_source(url_path):
pending.add(asyncio.create_task(worker(url)))
if len(pending) >= GLOBAL_CONCURRENCY * 2:
done, pending = await asyncio.wait(
pending, return_when=asyncio.FIRST_COMPLETED
)
if pending:
await asyncio.gather(*pending)
await results.put(None) # tell writer to stop
await writer_task
if __name__ == "__main__":
asyncio.run(scrape("urls.txt", "results.jsonl"))
ProcessPoolExecutor via
loop.run_in_executor (see async at scale), and you've
quietly turned Case Study 1 into a hybrid of Case Study 3.
Profiling one request: ~5ms DB, ~40ms downstream call (I/O wait),
~8ms JSON serialization (CPU, GIL-held), ~2ms framework. So per request it's
~85% I/O-bound, but with a real CPU tax on serialization that the GIL
serializes across all concurrent requests. And there's a downstream-bound failure mode:
when pricing slows to 2s, naive code lets requests pile up until the queue eats all memory.
64ms of CPU per second of serialization work per core โ need multiple worker
processes to parallelize the CPU part regardless of thread/async choice.
The honest tradeoff:
| Option | Pro | Con |
|---|---|---|
| Async (FastAPI + asyncpg + httpx) | Cheapest concurrency for the 40ms wait; thousands of in-flight requests | The 8ms serialization blocks the loop โ one slow encode stalls every coroutine. Must offload CPU, and the whole stack must be non-blocking (no accidental sync DB driver). |
Threads (sync framework + ThreadPoolExecutor) | Blocking DB/serialization is fine โ the GIL releases during I/O, so threads overlap the wait; simplest to reason about with sync libraries | Thread-per-request memory; GIL still serializes the CPU part (fixed by running several worker processes, e.g. gunicorn). |
We pick threads behind N worker processes (e.g. gunicorn -w 4 -k gthread):
it uses the team's existing sync DB and client code, the GIL-releasing I/O lets threads overlap
the downstream wait, and the 4 processes give the CPU parallelism serialization needs. Each process
owns its own sized SQLAlchemy pool. (Async is the better pick if
the whole stack is already non-blocking and concurrency is enormous โ see
async at scale.)
max_connections.503 immediately rather than queueing into a latency spiral.import threading
import time
import requests
from sqlalchemy import create_engine, text
# --- one sized pool per worker PROCESS (see connection_pooling) ---
engine = create_engine(
"postgresql+psycopg://app@db/prod",
pool_size=20, max_overflow=10, pool_pre_ping=True,
)
downstream = requests.Session() # pooled keep-alive conns to pricing service
# ---------- single-flight TTL cache (beats cache stampede) ----------
class SingleFlightCache:
def __init__(self, ttl: float):
self._ttl = ttl
self._store: dict[str, tuple[float, object]] = {}
self._locks: dict[str, threading.Lock] = {}
self._guard = threading.Lock()
def get(self, key: str, loader):
now = time.monotonic()
hit = self._store.get(key)
if hit and now - hit[0] < self._ttl:
return hit[1]
# only ONE thread loads a given key; the rest wait on its lock
with self._guard:
lock = self._locks.setdefault(key, threading.Lock())
with lock:
hit = self._store.get(key) # re-check: someone may have filled it
if hit and time.monotonic() - hit[0] < self._ttl:
return hit[1]
value = loader()
self._store[key] = (time.monotonic(), value)
return value
cache = SingleFlightCache(ttl=5.0)
# ---------- circuit breaker for the downstream ----------
class CircuitBreaker:
def __init__(self, fail_max: int, reset_after: float):
self._fail_max, self._reset_after = fail_max, reset_after
self._fails = 0
self._opened_at = 0.0
self._lock = threading.Lock()
def allow(self) -> bool:
with self._lock:
if self._fails < self._fail_max:
return True
if time.monotonic() - self._opened_at > self._reset_after:
self._fails = 0 # half-open: let one probe through
return True
return False # OPEN โ fail fast, don't tie up a thread
def record(self, ok: bool) -> None:
with self._lock:
if ok:
self._fails = 0
else:
self._fails += 1
self._opened_at = time.monotonic()
breaker = CircuitBreaker(fail_max=20, reset_after=5.0)
# ---------- the request handler (runs on a pool thread) ----------
class Overloaded(Exception):
"""Signals the framework to return 503 (load shedding)."""
def handle(product_id: str, queue_depth: int, queue_limit: int) -> dict:
# 1. LOAD SHEDDING: if the work queue is saturated, reject NOW, don't queue.
if queue_depth >= queue_limit:
raise Overloaded()
# 2. CACHE + single-flight: collapse duplicate concurrent misses into one DB read.
def load_product():
with engine.connect() as conn:
row = conn.execute(
text("SELECT id, name, base FROM products WHERE id = :id"),
{"id": product_id},
).one()
return dict(row._mapping)
product = cache.get(product_id, load_product)
# 3. DOWNSTREAM behind a circuit breaker + hard timeout.
price = product["base"]
if breaker.allow():
try:
r = downstream.get(f"http://pricing/quote/{product_id}", timeout=0.1)
r.raise_for_status()
price = r.json()["price"]
breaker.record(ok=True)
except requests.RequestException:
breaker.record(ok=False) # fall back to base price, stay up
# else: breaker OPEN -> skip the call entirely, serve base price fast
# 4. Serialization is the GIL-bound CPU cost โ parallelized by running N processes.
return {"id": product["id"], "name": product["name"], "price": price}
max_workers โ cores ร (1 + wait/cpu) gives a healthy pool โ but cap it so the queue
bound and the DB pool agree. Never let threads > pool_size or workers block on
connections (see thread pool tuning).
Profile a sample chunk: parsing + scoring is ~95% CPU, all of it pure-Python, all of it
GIL-held. Reading the file is trivial by comparison. This is textbook CPU-bound โ and
because of the GIL, threads and async give zero speedup here: they'd all fight for one core.
shared_memory.
Multiprocessing is the only model that gives real CPU parallelism in CPython โ each process has its own GIL, so 16 processes genuinely use 16 cores (see CPU-bound scaling and choosing the model). Threads/async are ruled out by the GIL.
ProcessPoolExecutor with a tuned chunksize to amortize the per-task pickling cost.SharedMemory block mapped read-only into every worker (loaded once, not 16ร).initializer= pattern attaches each worker to the shared block once at startup.executor.map with a bounded chunk stream keeps only a window of chunks in flight, so producers can't outrun consumers.import json
from concurrent.futures import ProcessPoolExecutor
from itertools import islice
from multiprocessing import shared_memory
WORKERS = 16
CHUNK_ROWS = 5_000 # rows per task โ big enough to dwarf pickling overhead
LOOKUP_SHM_NAME = "enrich_lookup"
# Each worker attaches to the shared lookup ONCE and reuses it for every chunk.
_lookup: dict | None = None
def build_shared_lookup(lookup: dict) -> shared_memory.SharedMemory:
"""Main process: serialize the 2 GB table into ONE shared block."""
import pickle
blob = pickle.dumps(lookup, protocol=5)
shm = shared_memory.SharedMemory(name=LOOKUP_SHM_NAME, create=True, size=len(blob))
shm.buf[: len(blob)] = blob
return shm
def init_worker() -> None:
"""Runs ONCE per worker process โ map the shared lookup, don't copy it."""
import pickle
global _lookup
shm = shared_memory.SharedMemory(name=LOOKUP_SHM_NAME) # attach, no copy
_lookup = pickle.loads(bytes(shm.buf)) # deserialize once per proc
# NOTE: this deserialization is the one unavoidable per-process cost; the raw
# 2 GB bytes are shared, so we pay RAM for the dict once per core, not the blob.
def score_chunk(rows: list[str]) -> list[str]:
"""Pure-CPU work โ parse, enrich against the shared lookup, score. Runs on its own core."""
assert _lookup is not None
out: list[str] = []
for line in rows:
rec = json.loads(line)
rec["region"] = _lookup.get(rec["zip"], "unknown") # enrichment
rec["score"] = heavy_score(rec) # the CPU-bound hotspot
out.append(json.dumps(rec))
return out
def heavy_score(rec: dict) -> float:
# stand-in for the real pure-Python scoring function (the GIL-bound hotspot)
return sum(ord(c) for c in rec.get("name", "")) / 100.0
def chunked(path: str, size: int):
"""Stream the 50 GB file in fixed-row chunks โ RAM stays flat regardless of file size."""
with open(path) as fh:
while True:
block = list(islice(fh, size))
if not block:
return
yield block
def run(in_path: str, out_path: str, lookup: dict) -> None:
shm = build_shared_lookup(lookup)
try:
with ProcessPoolExecutor(WORKERS, initializer=init_worker) as pool, \
open(out_path, "w") as out:
# map streams chunks in, yields results IN ORDER as they finish.
# chunksize=1 here because each `chunk` is already CHUNK_ROWS rows โ
# the batching is done by chunked(), not by the executor.
for scored_rows in pool.map(score_chunk, chunked(in_path, CHUNK_ROWS),
chunksize=1):
out.write("\n".join(scored_rows) + "\n") # stream results out in order
finally:
shm.close()
shm.unlink() # free the shared block
if __name__ == "__main__":
big_lookup = {str(z): f"region-{z % 50}" for z in range(2_000_000)} # stand-in 2GB table
run("input.jsonl", "output.jsonl", big_lookup)
chunked() means input RAM is bounded to WORKERS ร CHUNK_ROWS rows, not 300M;
shared_memory means the 2 GB lookup's bytes exist once instead of 16ร; and
pool.map streaming results in order lets us write output incrementally instead of
buffering it all. Every one of the pipeline's two constraints (CPU, memory) has its own guard.
The same bottleneck-first logic, three different answers:
| Workload | Bottleneck | Model | Key techniques |
|---|---|---|---|
| Web scraper 1M polite fetches |
I/O-bound (~99% wait) |
asyncio (thousands of cheap waits) |
shared AsyncClient pool ยท global + per-host Semaphore ยท bounded result Queue ยท retry/backoff ยท streamed I/O |
| JSON API 8k QPS, p99 SLA |
I/O per request + GIL-bound serialization + slow downstream |
threads ร N processes (or async if stack is non-blocking) |
sized DB pool/process ยท single-flight TTL cache ยท downstream timeout + circuit breaker ยท load shedding on full queue |
| Batch pipeline 50 GB CPU transform |
CPU-bound + memory (50 GB / 2 GB lookup) |
multiprocessing (only real parallelism) |
ProcessPoolExecutor + chunksize ยท streamed chunked input ยท shared_memory lookup ยท per-process initializer ยท in-order streamed output |