Concurrency#
Three concurrency models, each fit for a different kind of work.
Model |
Right for |
|---|---|
asyncio |
I/O-bound work with thousands of in-flight ops (HTTP, DB, sockets) |
threads |
I/O-bound work with libraries that don’t speak async |
processes |
CPU-bound work (numerical, parsing, crypto); bypasses the GIL |
The wrong pick costs you: threads on CPU-bound work hit the GIL; asyncio on CPU-bound work blocks the event loop; processes for chatty I/O drown in IPC overhead.
The GIL#
CPython has one Global Interpreter Lock: only one thread runs Python bytecode at a time. Implications.
CPU-bound Python code does not get faster with threads.
I/O-bound code does benefit, blocking system calls release the GIL.
C extensions that release the GIL (NumPy, hashlib, zlib) run in parallel under threads.
Python 3.13 ships an experimental free-threaded build (--disable-gil)
that removes this; not yet default and many C extensions need
recompilation.
asyncio#
A cooperative event loop; coroutines mark the points where they await I/O, and the loop runs other coroutines while one is blocked.
import asyncio, httpx
async def fetch(client, url):
r = await client.get(url, timeout=5)
return r.status_code
async def main(urls):
async with httpx.AsyncClient() as c:
return await asyncio.gather(*(fetch(c, u) for u in urls))
asyncio.run(main(urls))
Don’t mix sync-blocking calls into async code; offload them.
await asyncio.to_thread(blocking_call, arg) # for short blocking
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, work, arg) # for CPU work via threads
TaskGroup (3.11+) for structured concurrency, cleanup is
automatic, errors aggregate into an ExceptionGroup:
async with asyncio.TaskGroup() as tg:
a = tg.create_task(fetch_a())
b = tg.create_task(fetch_b())
Threads#
For I/O work via blocking libraries.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=16) as pool:
results = list(pool.map(http_get, urls))
# or single tasks
with ThreadPoolExecutor() as pool:
fut = pool.submit(work, x)
result = fut.result() # blocks for the answer
Synchronization primitives in threading: Lock, RLock,
Event, Semaphore, Condition, Queue.
Always prefer Queue over hand-rolled lock dances; the queue
handles producer / consumer hand-off correctly.
Processes#
For CPU-bound work that won’t release the GIL.
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
results = list(pool.map(crunch, chunks))
Caveats.
The function and its args must be picklable (top-level or importable; no closures over local state).
On macOS / Windows the default start method is
spawn, the child re-imports your module. Wrap entry point inif __name__ == "__main__":.Big arguments are pickled across the process boundary, use shared memory (
multiprocessing.shared_memory) for large arrays.
Choosing#
You have… |
Reach for |
|---|---|
many HTTP calls |
|
a slow CPU computation |
|
a single thing to retry |
|
fan-out across machines |
Job queue (Celery, RQ, Dramatiq) |
Watching it run#
$ python -X dev -X importtime -c '...' # dev mode, import timing
$ py-spy record -o flame.svg -- python script.py
$ py-spy top --pid <PID>
$ pyflame -r 0.001 -p <PID> # legacy
In code.
import asyncio, sys
asyncio.run(main(), debug=True) # extra checks + warnings
sys.settrace(...) # only for debuggers
Pitfalls#
GIL surprise, threads will not parallelise pure-Python loops. Profile first.
Blocking calls in asyncio silently freeze the event loop – even
time.sleepinstead ofawait asyncio.sleep.Mutable shared state across threads / processes, use
Queueor messages, not shared dicts.Forking with threads / open sockets, wrap with
multiprocessing.set_start_method("spawn")to dodge fork-after-thread footguns.asyncio + signal handlers, use
loop.add_signal_handler, notsignal.signal.
See also: https://docs.python.org/3/library/asyncio.html, https://docs.python.org/3/library/concurrent.futures.html, https://docs.python.org/3/library/multiprocessing.html.