Skip to content

Async & Concurrency

Python gives you three distinct concurrency models, each suited to a different class of problem:

ModelBest forMechanism
asyncioI/O-bound work (network, files, DB)Single thread, cooperative multitasking
threadingI/O-bound work that uses blocking libsOS threads, limited by the GIL
multiprocessingCPU-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.

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.

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 GIL
t1 = 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 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.

WorkloadFirst choiceAlso considerWrong 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-threadingFree-threaded python3.14t with threadingmultiprocessingDefault-build threading (no speedup)
CPU-bound, dependencies are GIL-only (most of the ecosystem today)multiprocessing / ProcessPoolExecutorconcurrent.interpreters (3.14, cheaper startup)threading (no speedup)
Many short-lived CPU tasks where process startup dominatesconcurrent.interpreters (3.14)ProcessPoolExecutor with a long-lived poolA fresh Pool() per task
Mixed: some I/O, some CPUasyncio + 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:

Terminal window
python3.14 -X dev script.py

This 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.

LessonCore concept
asyncio BasicsEvent loop, async def, await, asyncio.run, asyncio.gather, async with/async for
Async / AwaitTaskGroup vs gather, Semaphore, Queue, Lock races, retry/rate-limit/shutdown patterns
GIL & ThreadsWhat the GIL protects (and does not), ThreadPoolExecutor, daemon threads, free-threaded 3.14t
MultiprocessingProcesses 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.

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.

What you getBenefitCost
asyncioThousands of concurrent I/O waits on one thread, minimal memoryEvery library in the call chain must be async-native, or you are back to blocking the loop
threadingDrop-in for existing synchronous/blocking librariesNo CPU speedup on the default build; needs Lock for shared state
multiprocessingReal CPU parallelism, crash isolation between workersFull process startup cost; only picklable data and callables cross the boundary
Free-threaded 3.14tReal CPU parallelism with shared-memory simplicity of threadsSeparate binary; C-extension ecosystem support still catching up
Picking one tool for everythingSimpler codebase, one mental modelForces I/O-bound and CPU-bound work through whichever tool fits neither well
  • “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, asyncio is 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 dev and -W error::RuntimeWarning exist 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.

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 to asyncio regardless.
  • Does a coroutine ever call a synchronous, blocking function (requests.get, time.sleep, heavy computation) directly, instead of through to_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, swallowed CancelledError, lost updates on shared state) produce no visible symptom under a plain, unflagged run.
  • Is a lambda or local closure ever handed to multiprocessing/ProcessPoolExecutor? It fails with a real PicklingError the first time it actually runs on a worker, not at the point it was written.

🐛 Spot the bug

import asyncio
import 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.

Which Python concurrency model is best suited for CPU-bound tasks like numerical computation?
Why does asyncio handle thousands of concurrent I/O waits efficiently in a single thread?
What is the Global Interpreter Lock (GIL)?
Which statement about threading and CPU-bound Python work is correct?
For 5 IDs, roughly how long does fetch_all take, and why?
async def fetch_user(uid):
    time.sleep(0.2)
    return {"id": uid}

async def fetch_all(ids):
    return await asyncio.gather(*[fetch_user(i) for i in ids])
Run as-is with plain python3.14 script.py, nothing is printed. What changes if it is instead run as python3.14 -X dev script.py?
async def greet(name):
    return f"Hello, {name}!"

coro = greet("Python")
del coro
A workload spends 95% of its wall-clock time waiting on network responses from an async-native HTTP client. Which tool from the decision table fits best?