Python offers two different ways to run code in parallel, and understanding the GIL (Global Interpreter Lock) explains why each is suited to a different kind of workload.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
def io_task(n): # simulates I/O — e.g. a network request
time.sleep(1)
return n * 2
def cpu_task(n): # simulates heavy computation
return sum(i * i for i in range(10_000_000))
# Threading — good for I/O-bound work
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(io_task, [1, 2, 3, 4])) # runs concurrently, ~1s total
# Multiprocessing — good for CPU-bound work
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(cpu_task, [1, 2, 3, 4])) # runs on separate CPU cores
CPython (the standard implementation) has a GIL — only one thread can execute Python bytecode at any given instant, even on a multi-core machine. This means threading in Python does not speed up CPU-bound work (heavy computation) at all, since the threads still take turns on a single core. But threading does help with I/O-bound work, because a thread waiting on a network response or disk read releases the GIL during that wait, letting another thread run — this is exactly why ThreadPoolExecutor genuinely speeds up the io_task example above.
Multiprocessing sidesteps the GIL entirely by running separate Python processes, each with its own interpreter and memory space, and genuinely runs on multiple CPU cores in parallel — the right tool for CPU-bound work like heavy numerical computation. The trade-off: processes are heavier to start than threads, and don't share memory directly, so passing data between them requires explicit serialization (handled automatically by ProcessPoolExecutor for simple cases).
As a rule of thumb: I/O-bound → asyncio or threading; CPU-bound → multiprocessing. Choosing the wrong tool for the workload is a common and easy-to-make Python performance mistake.