Python gives you three concurrency models, and each solves a different bottleneck. Threads and asyncio handle waiting (network, disk). Processes handle computing (math, parsing, image work). Pick the wrong one and your code runs slower than the sequential version.
Quick Takeaways
- Use asyncio for thousands of concurrent network calls. It has the lowest overhead per task.
- Use threading for I/O-bound work when you rely on blocking libraries like
requestsor legacy database drivers. - Use multiprocessing for CPU-bound work. Separate processes bypass the GIL and use every core.
- Python 3.13 and later offer an optional free-threaded build, but the default interpreter still ships with the GIL.
| Model | Best For | Parallel CPU Execution | Memory Overhead | Typical Scale |
|---|---|---|---|---|
| threading | Blocking I/O | No (GIL) | Low (~8 MB stack reserved per thread) | Dozens to hundreds |
| multiprocessing | CPU-bound tasks | Yes | High (separate interpreter per process) | Number of cores |
| asyncio | Non-blocking I/O | No (single thread) | Very low (~KBs per task) | Thousands to tens of thousands |
Concurrency vs. Parallelism
Concurrency means managing multiple tasks in overlapping time periods. Parallelism means executing multiple tasks at the same instant on different CPU cores.
A single-core machine can be concurrent but never parallel. Python threads and asyncio give you concurrency. Only multiprocessing (and free-threaded builds) give you true parallelism for Python bytecode.
I/O-Bound vs. CPU-Bound Work
Diagnose the bottleneck before choosing a tool.
- I/O-bound: the program waits on something external. Examples: HTTP requests, file reads, database queries.
- CPU-bound: the program waits on the processor. Examples: hashing, matrix math, compression, parsing huge files.
A quick profile settles it. If CPU usage sits near 100% on one core, you are CPU-bound. If CPU usage is low while runtime is high, you are I/O-bound.
The Global Interpreter Lock (GIL)
The GIL is a mutex inside CPython that allows only one thread to execute Python bytecode at a time. It protects the interpreter’s reference-counting memory management from race conditions.
Consequences:
- Two threads running pure Python loops do not run in parallel.
- The GIL is released during blocking I/O calls and inside many C extensions (NumPy,
hashlibon large buffers). - That release is why threads still speed up I/O-bound programs.
Proving the GIL Effect
import time
from threading import Thread
def count_down(n: int) -> None:
"""Pure-Python CPU work: holds the GIL the entire time."""
while n > 0:
n -= 1
N = 50_000_000
# Sequential baseline
start = time.perf_counter()
count_down(N)
count_down(N)
print(f"Sequential: {time.perf_counter() - start:.2f}s")
# Two threads
start = time.perf_counter()
t1 = Thread(target=count_down, args=(N,))
t2 = Thread(target=count_down, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threaded: {time.perf_counter() - start:.2f}s")
On a standard GIL build, the threaded version runs about as long as the sequential one, and often slightly longer because of lock contention and context switching. Exact numbers vary by machine.
Threading: Concurrent I/O with Blocking Libraries
The threading module creates OS-level threads that share memory. The modern way to use it is concurrent.futures.ThreadPoolExecutor, which manages a worker pool for you.
Basic Thread Pool
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
URLS = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
def fetch(url: str) -> tuple[str, int]:
"""Blocking HTTP call. The GIL is released while waiting on the socket."""
response = requests.get(url, timeout=10)
return url, response.status_code
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(fetch, url) for url in URLS]
for future in as_completed(futures):
url, status = future.result() # Re-raises any exception from the worker
print(f"{status} <- {url}")
print(f"Elapsed: {time.perf_counter() - start:.2f}s")
Four one-second requests finish in roughly one second total instead of four. as_completed() yields futures as they finish, not in submission order.
Race Conditions and Locks
Threads share memory, so unsynchronized writes corrupt data. The += operator is not atomic: it reads, adds, then writes.
import threading
counter = 0
lock = threading.Lock()
def increment_safe(times: int) -> None:
global counter
for _ in range(times):
with lock: # Only one thread enters this block at a time
counter += 1
threads = [threading.Thread(target=increment_safe, args=(100_000,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # 400000, deterministic with the lock
Without the with lock: guard, the final value is often lower than 400,000 and changes between runs. Prefer queue.Queue for passing data between threads, since it handles locking internally.
Multiprocessing: True Parallelism for CPU-Bound Work
The multiprocessing module launches separate Python interpreters. Each has its own memory space and its own GIL. Data crossing process boundaries must be pickled (serialized), which adds overhead.
ProcessPoolExecutor
import time
from concurrent.futures import ProcessPoolExecutor
def is_prime(n: int) -> bool:
"""CPU-bound primality test, O(sqrt(n))."""
if n < 2:
return False
if n % 2 == 0:
return n == 2
i = 3
while i * i <= n:
if n % i == 0:
return False
i += 2
return True
NUMBERS = [112_272_535_095_293] * 8
if __name__ == "__main__": # Required on Windows and macOS (spawn start method)
start = time.perf_counter()
with ProcessPoolExecutor() as executor: # Defaults to os.cpu_count() workers
results = list(executor.map(is_prime, NUMBERS))
print(results)
print(f"Elapsed: {time.perf_counter() - start:.2f}s")
On an 8-core machine, this runs close to 8x faster than a sequential loop. The if __name__ == "__main__" guard is mandatory. Without it, child processes re-import the module and spawn processes recursively.
Sharing State Between Processes
Processes do not share variables. Use one of these instead:
multiprocessing.QueueorPipefor message passing.multiprocessing.shared_memoryfor large arrays without pickling cost.Managerobjects for shared dicts and lists (slower, proxy-based).
Keep inter-process traffic small. Pickling a 500 MB object to every worker can erase the speedup.
asyncio: Cooperative Concurrency on One Thread
asyncio runs a single-threaded event loop. Tasks voluntarily yield control at each await point. No preemption occurs, so you avoid most race conditions. The tradeoff: one blocking call freezes every task.
Core Vocabulary
| Term | Meaning |
|---|---|
| Coroutine | Function defined with async def. Calling it returns a coroutine object, not a result. |
await |
Suspends the current coroutine until the awaited operation completes. |
| Task | A coroutine scheduled on the event loop via asyncio.create_task(). |
| Event loop | The scheduler that switches between ready tasks. |
Concurrent HTTP with aiohttp
import asyncio
import time
import aiohttp
URLS = ["https://httpbin.org/delay/1"] * 50
async def fetch(session: aiohttp.ClientSession, url: str) -> int:
async with session.get(url) as response:
await response.read()
return response.status
async def main() -> None:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
# gather() schedules all coroutines at once and preserves result order
statuses = await asyncio.gather(*(fetch(session, u) for u in URLS))
print(f"{len(statuses)} requests, statuses: {set(statuses)}")
start = time.perf_counter()
asyncio.run(main())
print(f"Elapsed: {time.perf_counter() - start:.2f}s")
Fifty one-second requests complete in a little over one second, using one thread.
Structured Concurrency with TaskGroup
Since Python 3.11, asyncio.TaskGroup is the recommended way to run related tasks. If one task fails, the group cancels the rest and raises an ExceptionGroup.
import asyncio
async def worker(name: str, delay: float) -> str:
await asyncio.sleep(delay) # Non-blocking sleep
return f"{name} done"
async def main() -> None:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(worker("A", 1.0))
t2 = tg.create_task(worker("B", 0.5))
# Leaving the block guarantees both tasks have finished
print(t1.result(), t2.result())
asyncio.run(main())
Limiting Concurrency with a Semaphore
Launching 10,000 simultaneous requests can exhaust file descriptors or trigger rate limits. Cap it.
import asyncio
semaphore = asyncio.Semaphore(100) # At most 100 tasks inside the guarded block
async def limited_fetch(session, url):
async with semaphore:
async with session.get(url) as response:
return await response.text()
Calling Blocking Code from asyncio
Never call time.sleep() or requests.get() inside a coroutine. Offload it to a thread:
import asyncio
import time
def blocking_task() -> str:
time.sleep(2) # Blocking call, safe inside a worker thread
return "finished"
async def main() -> None:
result = await asyncio.to_thread(blocking_task) # Python 3.9+
print(result)
asyncio.run(main())
Choosing the Right Model
| Scenario | Best Choice | Reason |
|---|---|---|
| 10,000 concurrent API calls | asyncio | Lowest per-task memory cost |
Scraping 50 pages with requests |
ThreadPoolExecutor | Works with existing blocking code |
| Resizing 5,000 images | ProcessPoolExecutor | CPU-bound, needs multiple cores |
| Web server handling mixed load | asyncio + process pool | Async for I/O, processes for heavy compute |
| Calling a C library that releases the GIL | ThreadPoolExecutor | Threads run in parallel inside C code |
| Simple script, 3 slow downloads | ThreadPoolExecutor | Least code to write |
Combining Models
Run CPU-heavy work from async code with loop.run_in_executor():
import asyncio
from concurrent.futures import ProcessPoolExecutor
def crunch(n: int) -> int:
return sum(i * i for i in range(n))
async def main() -> None:
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
results = await asyncio.gather(
loop.run_in_executor(pool, crunch, 10_000_000),
loop.run_in_executor(pool, crunch, 20_000_000),
)
print(results)
if __name__ == "__main__":
asyncio.run(main())
The event loop stays responsive while worker processes burn CPU.
Free-Threaded Python (No-GIL Builds)
Python 3.13 introduced an experimental free-threaded build (PEP 703), and later releases continue to mature it. In that build, threads can execute Python bytecode in parallel. Key points:
- It is a separate interpreter build (often named
python3.13t), not the default. - Many C extensions need updates to be thread-safe on it.
- Single-threaded performance can be somewhat lower than the standard build.
- Check your dependencies before adopting it in production.
For now, treat multiprocessing as the portable answer for CPU-bound parallelism, and verify the status of free-threading in the release notes of your target Python version.
Best Practices and Refactoring
Anti-pattern 1: Blocking Call Inside a Coroutine
Bad code:
import asyncio
import requests
async def get_user(user_id: int) -> dict:
# Blocks the entire event loop for the duration of the request
return requests.get(f"https://api.example.com/users/{user_id}").json()
async def main():
users = await asyncio.gather(*(get_user(i) for i in range(100)))
This code runs sequentially. Each requests.get() freezes the loop.
Good code:
import asyncio
import aiohttp
async def get_user(session: aiohttp.ClientSession, user_id: int) -> dict:
async with session.get(f"https://api.example.com/users/{user_id}") as resp:
resp.raise_for_status()
return await resp.json()
async def main():
async with aiohttp.ClientSession() as session:
users = await asyncio.gather(*(get_user(session, i) for i in range(100)))
Anti-pattern 2: Threads for CPU-Bound Work
Bad code:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=8) as ex:
results = list(ex.map(heavy_math, data)) # GIL serializes this
Good code:
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
with ProcessPoolExecutor() as ex:
results = list(ex.map(heavy_math, data, chunksize=100))
The chunksize argument batches items per worker and cuts pickling overhead.
Anti-pattern 3: Unbounded Task Creation
Bad code:
tasks = [asyncio.create_task(fetch(u)) for u in million_urls]
await asyncio.gather(*tasks)
Good code:
sem = asyncio.Semaphore(200)
async def bounded(url):
async with sem:
return await fetch(url)
results = await asyncio.gather(*(bounded(u) for u in million_urls))
For very large inputs, process URLs in batches or use a worker queue with asyncio.Queue so you never hold a million coroutine objects at once.
Anti-pattern 4: Swallowing Exceptions
Bad code:
executor.submit(risky_job) # Exception is silently stored in the Future, never read
Good code:
future = executor.submit(risky_job)
try:
future.result(timeout=30)
except Exception as exc:
logger.exception("risky_job failed: %s", exc)
Checklist
- Set timeouts on every network call.
- Use context managers (
with,async with) so pools and sessions close cleanly. - Prefer
concurrent.futuresover rawThreadandProcessobjects. - Keep shared mutable state to a minimum. Pass messages instead.
- Benchmark with
time.perf_counter()before and after. Concurrency adds complexity, so measure the gain.
Common Edge Cases
- Windows and macOS
spawn: child processes re-import your module. Guard entry points withif __name__ == "__main__":. - Unpicklable arguments: lambdas, open file handles, and locks cannot cross process boundaries. Use top-level functions and plain data.
- Deadlocks: acquiring two locks in different orders across threads can freeze a program. Always acquire locks in a consistent order.
- Cancellation in asyncio: catching
asyncio.CancelledErrorwithout re-raising it breaks task cancellation. Clean up in afinallyblock and let it propagate. - Daemon threads: they die abruptly at interpreter exit and may skip cleanup. Avoid them for work that must finish.
FAQ
What is the difference between threading and multiprocessing in Python?
Threading runs multiple threads inside one process with shared memory, but the GIL lets only one execute Python bytecode at a time. Multiprocessing runs separate processes with separate memory and separate GILs, enabling true parallel CPU execution at the cost of higher memory use and serialization overhead.
Is asyncio faster than threading?
For high-volume I/O, usually yes. An asyncio task costs a few kilobytes, while an OS thread reserves megabytes of stack address space and incurs context-switch costs. For a few dozen concurrent calls, the speed difference is negligible, and threads are simpler when you depend on blocking libraries.
Does Python really have no true multithreading because of the GIL?
Python threads are real OS threads, but in the default build the GIL prevents parallel execution of Python bytecode. Threads still overlap during I/O and in C extensions that release the lock. Free-threaded builds (PEP 703) remove this limit but are not the default interpreter.
When should I use asyncio, threads, or processes?
Use asyncio for large-scale network I/O with async-compatible libraries. Use threads for moderate I/O with blocking libraries. Use processes for CPU-heavy work. Combine them when a workload has both traits, such as an async web service that offloads computation to a process pool.




