Threads vs Processes
What Problem Does It Solve?
You need to run code concurrently — either to speed things up or to keep the application responsive while doing I/O. A process is an independent execution environment with its own memory, while a thread is a lightweight unit of execution that shares memory within a process. Think of a restaurant: the whole restaurant is a process (has its own kitchen, staff, seating, and supplies), while each chef in the kitchen is a thread — they share the same kitchen, ingredients, and tools but work on separate orders concurrently.
In Python, the Global Interpreter Lock (GIL) allows only one thread to execute Python bytecode at a time. This means threads are useless for CPU-bound work (pure computation) but still work well for I/O-bound work (waiting for network, disk, databases). Processes bypass the GIL because each gets its own Python interpreter.
How to Identify When to Use It
- Task is CPU-bound (heavy math, video encoding, image processing, data crunching) → use multiprocessing
- Task is I/O-bound (HTTP requests, file reads, database queries, sleeping) → use threading or asyncio
- You need true parallel execution across CPU cores → use multiprocessing
- You need to share state between concurrent work → use threading (shared memory) or explicit IPC for processes
- You're writing a web server or scraping tool → use threading or asyncio
Questions to ask yourself: Does the task spend most of its time computing or waiting? Can the work be split into independent chunks? Do I need shared state?
Red flags: Using threads for heavy numeric computation and seeing no speedup; spawning a process per task for a lightweight I/O workload; not considering the GIL in Python.
How to Apply It
- Identify whether the work is CPU-bound or I/O-bound.
- For CPU-bound: use
ProcessPoolExecutorormultiprocessing.Pool. - For I/O-bound: use
ThreadPoolExecutororthreading.Thread. - Design tasks as independent functions. Avoid shared mutable state where possible.
- Submit tasks, collect results, measure performance.
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def cpu_work(n: int) -> int:
"""CPU-bound: pure computation (no GIL benefit for threads)."""
total = 0
for i in range(n):
total += i * i
return total
def io_work(_: int) -> str:
"""I/O-bound: simulates waiting (threads shine here)."""
time.sleep(1)
return "done"
def compare_executors(work_fn, items, label: str) -> None:
for name, Executor in [("Threads", ThreadPoolExecutor), ("Processes", ProcessPoolExecutor)]:
start = time.perf_counter()
with Executor(max_workers=4) as pool:
list(pool.map(work_fn, items))
elapsed = time.perf_counter() - start
print(f"{label} — {name}: {elapsed:.3f}s")
if __name__ == "__main__":
# CPU-bound: processes are ~4× faster on a 4-core machine
compare_executors(cpu_work, [5_000_000] * 8, "CPU-bound")
# I/O-bound: threads and processes perform similarly
compare_executors(io_work, list(range(8)), "I/O-bound")Real-World Example
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from typing import List
def download_snapshot(url: str) -> str:
"""Simulate downloading a JSON snapshot from an API."""
time.sleep(1.5) # network I/O
return f'{{"source": "{url}", "status": "ok"}}'
def process_snapshot(data: str) -> dict:
"""CPU-bound: parse and transform the JSON payload."""
n = 2_000_000
total = sum(i * i for i in range(n)) # heavy computation
return {"data": data, "checksum": total & 0xFFFF}
def pipeline(urls: List[str]) -> List[dict]:
"""I/O stage with threads, CPU stage with processes."""
# Stage 1 — I/O-bound: download concurrently via threads
with ThreadPoolExecutor(max_workers=8) as pool:
raw = list(pool.map(download_snapshot, urls))
# Stage 2 — CPU-bound: process concurrently via processes
with ProcessPoolExecutor(max_workers=4) as pool:
return list(pool.map(process_snapshot, raw))
if __name__ == "__main__":
urls = [f"https://api.example.com/snapshots/{i}" for i in range(8)]
start = time.perf_counter()
results = pipeline(urls)
elapsed = time.perf_counter() - start
print(f"Processed {len(results)} snapshots in {elapsed:.2f}s")
for r in results:
print(r)Common Mistakes / Pitfalls
- Using threads for CPU-bound work in Python: the GIL prevents parallel execution, so you'll see no performance gain (often worse due to overhead).
- Using processes for I/O-bound work: process creation is expensive; threads are much lighter for waiting tasks.
- Ignoring serialization cost:
ProcessPoolExecutorpickles data between processes. Large objects incur significant overhead. - Not handling
if __name__ == "__main__": multiprocessing on Windows and macOS requires this guard to avoid infinite spawning. - Oversubscribing CPU cores: setting
max_workershigher than the number of CPU cores for CPU-bound work causes context-switching overhead.
Related Concepts
- Async/Await (asyncio) — alternative to threading for I/O-bound work with lower overhead (async-await.md)
- Synchronization (Locks, Mutex, Semaphore) — required when threads share mutable state (synchronization.md)
- Deadlock & Starvation — concurrency hazards that arise from improper synchronization (deadlock-starvation.md)
- GIL — Python-specific limitation that affects threading performance
