Async & Concurrency
Three tools, three problems
Section titled “Three tools, three problems”Python gives you three distinct concurrency models, each suited to a different class of problem:
| Model | Best for | Mechanism |
|---|---|---|
| asyncio | I/O-bound work (network, files, DB) | Single thread, cooperative multitasking |
| threading | I/O-bound work that uses blocking libs | OS threads, limited by the GIL |
| multiprocessing | CPU-bound work (computation, parsing) | Separate processes, true parallelism |
Choosing the wrong model is the most common source of performance bugs in Python.
A blocking database call inside a threading.Thread may look fine, but that same call inside an asyncio coroutine will freeze the entire event loop.
Why asyncio dominates modern Python
Section titled “Why asyncio dominates modern Python”Most server-side Python work is I/O-bound: waiting for a database, an HTTP API, a file read.
asyncio handles thousands of simultaneous waits inside a single OS thread by running an event loop that yields control between coroutines whenever one is blocked on I/O.
import asyncio
async def fetch(url: str) -> str: await asyncio.sleep(0.1) # simulates a network delay return f"Response from {url}"
async def main(): results = await asyncio.gather( fetch("https://api.example.com/a"), fetch("https://api.example.com/b"), ) for r in results: print(r)
asyncio.run(main())Both fetches run concurrently — neither blocks the other. Total time is roughly 0.1 s, not 0.2 s.
The GIL and threads
Section titled “The GIL and threads”CPython holds a lock called the Global Interpreter Lock (GIL) that allows only one thread to execute Python bytecode at a time. For I/O-bound work this does not matter — threads release the GIL while waiting for the OS — but for CPU-bound work (tight loops, number crunching), adding threads provides zero speedup.
import threading
def count_down(n: int) -> None: while n > 0: n -= 1
# Two threads — still runs on one core due to the GILt1 = threading.Thread(target=count_down, args=(5_000_000,))t2 = threading.Thread(target=count_down, args=(5_000_000,))t1.start(); t2.start()t1.join(); t2.join()Python 3.14 adds two more ways out of this corner: the officially supported free-threaded build (python3.14t), where threads really do run in parallel, and concurrent.interpreters, which gives each worker its own interpreter and GIL inside a single process. Both are covered in the GIL and Multiprocessing lessons.
Multiprocessing for real parallelism
Section titled “Multiprocessing for real parallelism”multiprocessing spawns separate OS processes, each with its own Python interpreter and GIL.
CPU-bound work can therefore use all available cores.
from multiprocessing import Pool
def square(x: int) -> int: return x * x
if __name__ == "__main__": # required on every platform with Pool(processes=4) as pool: results = pool.map(square, range(10)) print(results) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]Run this locally only — there are no real processes in the browser sandbox. Always include the
if __name__ == "__main__":guard, on every platform: without it, macOS/Windows (spawn) will re-import this module in every child process and spawn processes forever. See the Multiprocessing lesson for the full guard/start-method guide.
Decision table: which tool for which job
Section titled “Decision table: which tool for which job”| Workload | First choice | Also consider | Wrong tool |
|---|---|---|---|
I/O-bound, async-native library available (httpx, asyncpg) | asyncio | — | threading (works, but more overhead per connection) |
I/O-bound, only a blocking/synchronous library exists (requests, boto3, psycopg2) | threading or asyncio.to_thread | — | Calling the blocking library directly inside a coroutine |
| CPU-bound, dependencies support free-threading | Free-threaded python3.14t with threading | multiprocessing | Default-build threading (no speedup) |
| CPU-bound, dependencies are GIL-only (most of the ecosystem today) | multiprocessing / ProcessPoolExecutor | concurrent.interpreters (3.14, cheaper startup) | threading (no speedup) |
| Many short-lived CPU tasks where process startup dominates | concurrent.interpreters (3.14) | ProcessPoolExecutor with a long-lived pool | A fresh Pool() per task |
| Mixed: some I/O, some CPU | asyncio + run_in_executor/to_thread for blocking parts, ProcessPoolExecutor for CPU parts | — | Picking one tool and forcing everything through it |
The single question that decides the first three rows: does this workload spend most of its time
waiting, or most of its time computing? Waiting (network, disk, a database round trip) means the
GIL is irrelevant — grab asyncio if the library supports it, threading otherwise. Computing
(parsing, number crunching, image processing) means the GIL is the bottleneck — reach for
multiprocessing, concurrent.interpreters, or a free-threaded build.
-X dev surfaces bugs that a normal run hides
Section titled “-X dev surfaces bugs that a normal run hides”Every async/threading/multiprocessing bug pattern in this module — a never-awaited coroutine, a
ResourceWarning from an unclosed resource, a thread exception that would otherwise vanish — shares
one property: it fails silently under a normal python3.14 script.py run. -X dev mode turns
many of these into visible warnings without changing your code at all:
python3.14 -X dev script.pyThis is why every playground in this module is verified with python3.14 -X dev -W error::RuntimeWarning
rather than a plain python3.14 — -W error::RuntimeWarning goes one step further than -X dev and
turns the “coroutine was never awaited” warning into an actual exception, which is exactly the
behavior you want in a test suite or CI pipeline: a forgotten await should fail the build, not
print a warning that scrolls past in a log nobody reads.
What this module covers
Section titled “What this module covers”| Lesson | Core concept |
|---|---|
| asyncio Basics | Event loop, async def, await, asyncio.run, asyncio.gather, async with/async for |
| Async / Await | TaskGroup vs gather, Semaphore, Queue, Lock races, retry/rate-limit/shutdown patterns |
| GIL & Threads | What the GIL protects (and does not), ThreadPoolExecutor, daemon threads, free-threaded 3.14t |
| Multiprocessing | Processes vs threads, ProcessPoolExecutor, picklability, subinterpreters |
Each lesson is self-contained. Start with asyncio if you are building network services; jump to multiprocessing if you need to parallelize CPU-bound computation.
Mental Model
Section titled “Mental Model”Think of the three models as three different ways to staff a job: asyncio is one very well
organized worker who never sits idle — while waiting on one thing, they immediately pick up the next
ready task, and switch back the instant the first one is ready. threading is several workers
sharing one desk with one exclusive-use tool (the GIL) — only one can touch the tool at a time, so
they only get ahead of a single worker when they spend most of their time doing something that does
not need the tool (waiting on I/O). multiprocessing is several workers with entirely separate
desks and no shared tools at all — genuinely parallel, but everything they hand each other has to go
through the mail (pickle), which costs time and forbids anything that cannot be addressed by name.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
asyncio | Thousands of concurrent I/O waits on one thread, minimal memory | Every library in the call chain must be async-native, or you are back to blocking the loop |
threading | Drop-in for existing synchronous/blocking libraries | No CPU speedup on the default build; needs Lock for shared state |
multiprocessing | Real CPU parallelism, crash isolation between workers | Full process startup cost; only picklable data and callables cross the boundary |
| Free-threaded 3.14t | Real CPU parallelism with shared-memory simplicity of threads | Separate binary; C-extension ecosystem support still catching up |
| Picking one tool for everything | Simpler codebase, one mental model | Forces I/O-bound and CPU-bound work through whichever tool fits neither well |
Common Misconceptions
Section titled “Common Misconceptions”- “asyncio is always the fastest option.” It is the best option for I/O-bound work with async-native libraries. For CPU-bound work it does nothing at all — a tight loop inside a coroutine still blocks the entire event loop just as it would block a thread.
- “Threads and processes are interchangeable ways to get parallelism.” Threads share memory and
are limited by the GIL for CPU-bound work; processes bypass the GIL but do not share memory, so
every argument and result travels as pickled data.
Free-threaded Python narrows this gap for pure-Python workloads, but only on
python3.14t. - “If I’m not sure,
asynciois a safe default.” It is only safe if every library on the call path is async-native or explicitly offloaded (to_thread,run_in_executor). Adding one synchronous, blocking call inside a coroutine silently freezes every other coroutine on the loop. - “A silent bug in a threading/asyncio program means the tool doesn’t work.” Almost always the
opposite:
-X devand-W error::RuntimeWarningexist precisely because these bugs (never-awaited coroutines, unclosed resources, swallowed exceptions in threads) are real and common — turn them on in every test suite rather than debugging them by trial and error in production.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated concurrency code of any kind in this module, ask:
- Is the chosen tool (
asyncio/threading/multiprocessing) actually matched to whether the workload is I/O-bound or CPU-bound? Generated code frequently defaults toasyncioregardless. - Does a coroutine ever call a synchronous, blocking function (
requests.get,time.sleep, heavy computation) directly, instead of throughto_thread/run_in_executor/a process pool? - Does the code run — or does its test suite run — under
-X dev/-W error::RuntimeWarning? Most of the bug patterns across this module (never-awaited coroutines, swallowedCancelledError, lost updates on shared state) produce no visible symptom under a plain, unflagged run. - Is a
lambdaor local closure ever handed tomultiprocessing/ProcessPoolExecutor? It fails with a realPicklingErrorthe first time it actually runs on a worker, not at the point it was written.
🐛 Spot the bug
import asyncioimport time
async def fetch_user(user_id: int) -> dict: time.sleep(0.2) # "quick" call to a synchronous ORM return {"id": user_id, "name": f"user-{user_id}"}
async def fetch_all_users(ids: list[int]) -> list[dict]: return await asyncio.gather(*[fetch_user(i) for i in ids])Show the answer
This looks like idiomatic asyncio — async def, await, gather for concurrency — and it does
return the right list of user dicts. But fetch_user calls time.sleep, a genuinely blocking call,
directly inside a coroutine instead of an async-native ORM call or asyncio.to_thread. Because
time.sleep does not yield control back to the event loop, gather provides zero actual
concurrency here: every “concurrent” fetch still runs one after another on the single event-loop
thread, taking 0.2 * len(ids) seconds total instead of roughly 0.2 seconds. The bug produces no
exception and no warning — only a runtime that silently does not scale, which is exactly why
profiling for “which coroutine actually released control” matters more than reading the async/
await keywords and assuming concurrency is happening.