Python ships three concurrency models in the standard library: threading, multiprocessing, and asyncio. They look like they're competing, but they actually solve different problems. The choice depends on two axes: whether your work is CPU-bound or I/O-bound, and how many concurrent operations you need to support. Pick the wrong model and you'll fight the GIL, pay unnecessary IPC overhead, or watch the event loop stall on blocking calls. Pick the right one and Python scales much further than its single-threaded reputation suggests. It's worth noting that the three models aren't substitutes for one another, and serious applications often combine all three within a single codebase. This entry walks through the three models, explains what the GIL actually limits (the most-misunderstood Python concept), and lays out a 2x2 quadrant that maps workload type to the right tool. PEP 703 free-threaded Python changes one specific cell of the quadrant, and the deep dive on that is the next entry in this series. This guide is part of our broader Modern Python 2026 guide, which covers the full modern stack from type hints to free-threading.
Key Takeaways
asyncio: I/O-bound, high concurrency. Single thread, thousands of connections, lowest memory.
threading: I/O-bound, sync libraries only. Dozens of workers; the GIL releases during I/O so threads make progress.
multiprocessing: CPU-bound parallelism. Each process has its own GIL, so multiple cores actually work.
The GIL blocks CPU-bound threading. No amount of threads helps if you're computing; use processes instead.
concurrent.futures unifies threading and multiprocessing behind one API. Easy to swap models.
Two axes, four quadrants. Pick the model by workload type and concurrency need; reach for the simplest tool that fits.
The Fundamental Axis: CPU-Bound vs I/O-Bound
The single most important question for any concurrency decision: where does your time go? Two kinds of work behave completely differently.
I/O-bound work
Most time is spent WAITING. Network calls, database queries, file reads, polling external services. The CPU is mostly idle. Concurrency wins because while one operation waits, another can use the CPU.
CPU-bound work
Most time is spent COMPUTING. Numerical math, image processing, parsing, encryption. The CPU is fully busy. Concurrency wins only if you can use multiple cores simultaneously.
The right concurrency model differs sharply between these two cases. Mixing them up is the most common cause of "I added threads but it got slower" bug reports.
The GIL: What It Actually Limits
The Global Interpreter Lock is a mutex inside the CPython interpreter that ensures only one thread executes Python bytecode at any moment. The GIL releases automatically during I/O operations (network reads, file reads, time.sleep, most C extension calls). Two implications:
Threading works for I/O-bound work: while one thread waits on a socket, the GIL releases and other threads can run.
Threading does NOT help CPU-bound work: even on a 32-core machine, only one thread runs Python bytecode at a time. Adding threads just adds context-switch overhead.
This is why multiprocessing exists. Each process has its own Python interpreter and its own GIL. Run two processes on two cores and you actually get parallel computation. The cost is IPC: data shared between processes must be serialized.
PEP 703 (officially supported in Python 3.14 via PEP 779) introduces a free-threaded CPython build with the GIL removed. On that build, threading is also viable for CPU-bound work. See the free-threaded deep dive.
threading: I/O-Bound, No Async Library
The threading module is the simplest concurrency tool: each thread is an OS-level worker that runs Python bytecode. Cheap to start (microseconds), shares memory with other threads, releases the GIL during I/O.
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url: str) -> int:
return len(requests.get(url).content)
urls = [f"https://api.example.com/{i}" for i in range(100)]
with ThreadPoolExecutor(max_workers=10) as pool:
sizes = list(pool.map(fetch, urls))
10 worker threads fetch the URLs concurrently. The requests library is synchronous (it doesn't know about async), but threading still helps because the GIL releases while each thread waits on the socket.
When to choose threading over asyncio:
You depend on sync-only libraries (older HTTP clients, sync database drivers, third-party SDKs without async support).
The concurrency level is modest (dozens to low hundreds of concurrent operations).
You don't want to rewrite existing sync code into coroutines.
When threading falls short: scale (memory per thread caps you around hundreds), CPU-bound work (the GIL prevents parallelism), or any case where you'd benefit from async/await semantics.
multiprocessing: CPU-Bound, Bypass the GIL
For CPU-bound work, only multiple processes give actual parallelism on the standard CPython build. The multiprocessing module spawns OS processes that run independent Python interpreters.
from concurrent.futures import ProcessPoolExecutor
import os
def heavy_compute(n: int) -> int:
return sum(i * i for i in range(n)) # CPU-heavy
inputs = [10_000_000] * 8
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
results = list(pool.map(heavy_compute, inputs))
On an 8-core machine, all 8 processes run in parallel. Each process has its own interpreter, its own GIL, and its own memory. The OS schedules them on different cores.
Costs:
Memory: 10-50 MB per process (each is a full interpreter).
Startup: hundreds of milliseconds per process.
IPC: data sent to and from workers is pickled; large objects can dominate the cost.
Rule of thumb: multiprocessing wins when compute time per task is at least 100 milliseconds. Below that, the IPC overhead eats the gain.
For numerical work, libraries like NumPy, Pandas, and PyTorch often release the GIL inside their C extensions, so threading can sometimes parallelize them too. But for pure-Python CPU work, multiprocessing is the standard answer.
asyncio: I/O-Bound at Scale
Covered in depth in the async/await mental model guide. The summary here: a single thread runs an event loop juggling thousands of concurrent operations, each yielding control at await points.
import asyncio
import aiohttp
async def fetch(session: aiohttp.ClientSession, url: str) -> int:
async with session.get(url) as response:
return len(await response.read())
async def main():
urls = [f"https://api.example.com/{i}" for i in range(10_000)]
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(session, u)) for u in urls]
return [t.result() for t in tasks]
asyncio.run(main())
10,000 concurrent fetches in a single thread. Total memory is a few hundred megabytes, not the dozens of GB you'd need for 10,000 threads.
When to choose asyncio:
Massive concurrency (thousands of operations).
I/O-bound work with async-aware libraries (aiohttp, asyncpg, motor for MongoDB).
Servers handling many connections (FastAPI, Sanic, Starlette).
When asyncio falls short: CPU-bound work (still single-threaded), sync-only libraries (would block the loop unless wrapped with asyncio.to_thread), small concurrency counts where threading is simpler.
concurrent.futures: One API, Two Models
The concurrent.futures module provides a unified API for thread-based and process-based pools. ThreadPoolExecutor and ProcessPoolExecutor share the same interface (submit, map, shutdown), so swapping between them is a one-line change.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# For I/O-bound work:
with ThreadPoolExecutor(max_workers=20) as pool:
results = list(pool.map(fetch_url, urls))
# For CPU-bound work, change ONE line:
with ProcessPoolExecutor(max_workers=8) as pool:
results = list(pool.map(heavy_compute, inputs))
Prefer concurrent.futures over the raw threading.Thread or multiprocessing.Process APIs for most code. The high-level executor handles pool management, exception propagation, and result ordering automatically.
Decision Rules
Workload
Concurrency Level
Pick
Why
I/O-bound (network, DB, files)
thousands+
asyncio
Single thread scales to thousands of connections; lowest memory.
I/O-bound, sync libraries only
dozens to hundreds
threading + ThreadPoolExecutor
GIL releases during I/O; no async rewrite needed.
CPU-bound, multi-core machine
cores
multiprocessing + ProcessPoolExecutor
Each process bypasses the GIL; true parallelism.
CPU-bound, single task
1
sequential
No concurrency benefit; simplest code wins.
Mixed (I/O + heavy compute)
varies
asyncio + asyncio.to_thread or process pool
Run async for I/O, dispatch compute to processes via run_in_executor.
CPU-bound on free-threaded build
cores
threading (if no-GIL build)
PEP 703/779: threads parallelize on the free-threaded interpreter.
Real-World Example: Mixed Workload
A web service that fetches data from external APIs (I/O-bound) and runs ML inference on the results (CPU-bound). The pattern:
import asyncio
from concurrent.futures import ProcessPoolExecutor
# Process pool for the CPU-bound inference
_inference_pool = ProcessPoolExecutor(max_workers=4)
async def fetch_payload(session, url) -> bytes:
async with session.get(url) as response:
return await response.read()
def run_inference(payload: bytes) -> dict:
# CPU-heavy: returns prediction
return {"label": "cat", "score": 0.9}
async def process_url(session, url) -> dict:
payload = await fetch_payload(session, url)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(_inference_pool, run_inference, payload)
async def main(urls):
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(process_url(session, u)) for u in urls]
return [t.result() for t in tasks]
asyncio for the I/O part (massive concurrency), run_in_executor with a process pool for the CPU part (parallel inference). The event loop stays responsive; the CPU work uses all cores. This composition is the production pattern for ML serving, batch transformers, and similar mixed workloads.
The Free-Threaded Shift
PEP 703 (the no-GIL build) and PEP 779 (its official support in Python 3.14) change one cell of the quadrant. On the free-threaded build, threading is also viable for CPU-bound work, removing the historical reason to reach for multiprocessing. The implications:
Threading becomes a stronger default for CPU-bound parallelism (less IPC overhead).
Multiprocessing keeps its niche for true process isolation, sandboxing, or memory limits.
asyncio is unaffected: it's single-threaded by design.
The free-threaded build is officially supported in 3.14 but optional (the with-GIL build is still the default). Library compatibility varies; C extensions may need rebuilds. Covered in the PEP 703 explainer.
Common Mistakes
1. Using threads for CPU-bound work on standard CPython
The GIL prevents parallel execution. Adding more threads makes it slower (more context-switching, no extra throughput). Use processes for CPU-bound work.
2. Using processes for tiny tasks
Process startup costs hundreds of milliseconds and IPC pickling adds more. If each task takes 5 milliseconds, the overhead dominates. Batch tasks or use threading.
3. Mixing blocking calls with asyncio
Calling sync blocking code in a coroutine freezes the event loop. Use asyncio.to_thread for I/O blocking or loop.run_in_executor with a process pool for CPU work.
4. Sharing mutable state across processes
Each process has its own memory. Mutating a list in one process doesn't affect others. Use multiprocessing.Queue or multiprocessing.Manager for shared state, or restructure the work to avoid sharing.
5. Spawning too many threads or processes
Threads: memory grows with count (1-8 MB each); past a few hundred, you hit OS limits. Processes: memory grows even faster (10-50 MB each); aim for one per CPU core. Use a pool with bounded size, not unbounded thread or process creation.
Frequently Asked Questions
When should I use threading vs multiprocessing vs asyncio in Python?
The choice depends on two axes: whether the work is I/O-bound or CPU-bound, and how many concurrent operations you need. Use asyncio for I/O-bound work at high concurrency (thousands of network connections, paginated API consumers, web servers). Use threading for I/O-bound work that uses sync-only libraries (older HTTP libraries, sync database drivers) and where dozens of concurrent operations are enough. Use multiprocessing for CPU-bound work that needs to actually use multiple cores (image processing, numerical computation, parsing). The GIL prevents threading from helping CPU-bound code; multiprocessing sidesteps it by giving each process its own interpreter and GIL.
Why doesn't threading help with CPU-bound work in Python?
Because of the Global Interpreter Lock (GIL): a mutex inside the CPython interpreter that ensures only one thread executes Python bytecode at any given moment. The GIL releases during I/O operations (network calls, file reads, time.sleep), which is why threading works for I/O-bound code. But for CPU-bound code, the GIL is held the entire time, so even on a 16-core machine the threads take turns running serially. Multiprocessing sidesteps the GIL by giving each process its own interpreter; the free-threaded build added in PEP 703 / 779 removes the GIL entirely but is still optional in Python 3.14.
What does concurrent.futures do and when should I use it?
concurrent.futures is a high-level standard-library API that unifies threading and multiprocessing behind one interface: ThreadPoolExecutor and ProcessPoolExecutor. Both expose the same submit and map methods, so swapping between thread-based and process-based execution is a one-line change. Use it for clean, portable concurrent code that may need to switch models. The API is also the backbone behind asyncio.to_thread, which dispatches blocking work to a default thread pool. Reach for it whenever you'd manually manage a pool of workers; the standard library handles the bookkeeping.
Does PEP 703 free-threaded Python change which model I should use?
It changes one specific case: threading becomes viable for CPU-bound work on the free-threaded build (no GIL) introduced in Python 3.13 experimentally and made officially supported in 3.14 via PEP 779. For projects that opt into the free-threaded build, multiple threads can now execute Python bytecode in parallel, which means threading is useful for CPU-bound parallelism without the IPC overhead of multiprocessing. For the standard with-GIL build (still the default in 3.14), the existing guidance holds: asyncio or threading for I/O, multiprocessing for CPU. The free-threaded build is covered in detail in our dedicated PEP 703 guide.
How much overhead does multiprocessing add compared to threading?
Multiprocessing carries real overhead: each process is a full Python interpreter, costing typically 10-50 MB of memory per worker. Process startup takes hundreds of milliseconds, and data shared between processes must be pickled and sent through inter-process communication (queues, pipes, shared memory). For workloads with small inputs and small outputs but heavy compute, the overhead is amortized and worth it. For workloads with large data shared back and forth, the IPC cost can dominate. Threading uses 1-8 MB per thread, starts in microseconds, and shares memory directly. The rule of thumb: multiprocessing wins when compute time per task is at least 100 milliseconds; below that, the overhead eats the gain.
The Bottom Line: Pick by Workload and Scale
Three models, two axes. asyncio for I/O-bound at thousand-plus concurrency. Threading for I/O-bound with sync libraries at modest concurrency. Multiprocessing for CPU-bound work that needs real parallelism. The GIL blocks threads from helping CPU-bound code on the standard build; free-threaded Python (PEP 703/779) changes that on the no-GIL build. concurrent.futures unifies threading and multiprocessing behind one API; reach for it before the raw modules. Up next: the PEP 703 free-threaded deep dive. Or browse the full Modern Python 2026 guide.
Pick the Right Model Through Practice
CodeGym's Python track puts threading, multiprocessing, and asyncio side by side in 800+ hands-on tasks across 62 levels: pool-based fetchers, CPU-bound batch jobs, async API servers. AI validators catch the classic mistakes (threads for compute, processes for tiny tasks, blocking calls in coroutines); AI mentors explain why one model fits better than another for the workload at hand. First level free; full plan on the pricing page.
GO TO FULL VERSION