Part 3 · 2 chapters · ~12 min

Concurrency

Threads for IO and the GIL for CPU (measured), multiprocessing and concurrent.futures, asyncio with tasks, TaskGroup and timeouts, async database and HTTP clients, mixing sync and async code safely, Celery for background work, and free-threaded Python.

6

Threads, processes and the GIL, measured

code
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
with ThreadPoolExecutor(max_workers=16) as ex:            # IO-bound: fetch 100 statements concurrently
    results = list(ex.map(fetch_statement, account_ids))
with ProcessPoolExecutor() as ex:                          # CPU-bound: score 10k loan applications
    scores = list(ex.map(score_application, applications, chunksize=200))
CPU WORK ON THREADS VS PROCESSES
four tasks of 3M multiply-adds each, CPython 3.14.5 on this machine
serial466 ms4 threads455 ms4 processes227 ms
swipe the figure sideways, or tap expand for full screen
1/4
serial baseline
Running the four tasks one after another took 466 ms.
466 ms serialthe baseline
7

asyncio

code
import asyncio, httpx
async def fetch_all(ids: list[str]) -> list[dict]:
    sem = asyncio.Semaphore(10)
    async with httpx.AsyncClient(timeout=5) as client:
        async def one(i: str) -> dict:
            async with sem:
                r = await client.get(f"https://ledger.internal/accounts/{i}")
                r.raise_for_status(); return r.json()
        async with asyncio.TaskGroup() as tg:                  # 3.11+: cancel siblings on failure
            tasks = [tg.create_task(one(i)) for i in ids]
    return [t.result() for t in tasks]

# never call blocking code inside async functions; offload it
result = await asyncio.to_thread(blocking_pdf_render, statement)

Mixing sync and async: a blocking call (requests, time.sleep, a sync ORM query) inside an async view stalls the whole event loop. Django provides sync_to_async and async_to_sync to cross the boundary; async views need async-capable libraries all the way down to benefit.