Every Python developer eventually hits a wall where their code needs to do more than one thing “at once,” and then discovers that Python offers not one but three completely different answers: threading, multiprocessing, and asyncio. They all live in the standard library, they all let you write code that overlaps in time, and their APIs even look superficially similar. But they solve different problems, and picking the wrong one doesn’t just underperform, it can silently fail to help at all.
I’ve seen teams reach for multiprocessing to speed up a network-bound scraper (wrong tool, huge memory overhead for no benefit) and reach for threading to speed up a CPU-bound image resizer (wrong tool, the GIL means it barely beats single-threaded). The fix in both cases wasn’t more code, it was picking the model that actually matches the bottleneck.
The core question: what’s actually blocking you?
Before touching any of these three tools, answer one question: is your program waiting on something outside the CPU, or is it burning CPU cycles?
- I/O-bound: waiting on a network response, a disk read, a database query, a subprocess. The CPU is idle almost the whole time, it’s just sitting there waiting.
- CPU-bound: crunching numbers, parsing large files, resizing images, running a model. The CPU is pegged the whole time.
This single distinction determines which tool actually helps. Get it backwards and you’ll add complexity for zero speedup.
Why the GIL matters here
The elephant in the room is the Global Interpreter Lock (GIL). In the standard CPython implementation, only one thread can execute Python bytecode at a time, no matter how many threads you spin up. This is true even on an 8-core machine.
This one fact explains almost everything about why these three tools exist and behave so differently:
threadinggives you real OS threads, but they still take turns holding the GIL. Threads do release the GIL while waiting on I/O (a network call, a file read), so threading is genuinely useful for I/O-bound work. But threads competing for CPU-bound work don’t get real parallelism, they get expensive time-slicing.multiprocessingsidesteps the GIL entirely by using separate OS processes, each with its own Python interpreter and its own GIL. That’s real parallelism across CPU cores, at the cost of heavier startup, more memory, and the need to serialize data (pickle) to move it between processes.asynciodoesn’t use OS threads or processes at all for the concurrency itself. It runs everything on a single thread with a single event loop, and achieves concurrency by cooperatively switching between tasks whenever one of them hits anawaiton something that isn’t ready yet. It never fights the GIL because there’s nothing to fight, only one thing is ever “running” at a time, they’re just very good at taking turns during I/O waits.
(As of recent CPython versions there’s ongoing work on a no-GIL build (PEP 703), but for most people running stock Python today, the GIL is still the operating reality. Don’t plan around its absence yet.)
threading: good for I/O, not for CPU
import threading
import requests
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
results = []
def fetch(url):
response = requests.get(url)
results.append(response.status_code)
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
print(results) # takes ~1 second total, not ~3
Each requests.get call blocks waiting on the network, and while it’s blocked, that thread releases the GIL so another thread can run. Three requests that would take 3 seconds sequentially finish in about 1 second running concurrently on threads.
Now watch the same pattern fail to help on CPU-bound work:
import threading
import time
def cpu_heavy(n):
total = 0
for i in range(n):
total += i * i
return total
start = time.perf_counter()
threads = [threading.Thread(target=cpu_heavy, args=(20_000_000,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"threaded: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
for _ in range(4):
cpu_heavy(20_000_000)
print(f"sequential: {time.perf_counter() - start:.2f}s")
Run this and the “threaded” version is not meaningfully faster than the sequential one, sometimes slightly slower thanks to thread-switching overhead. The four threads are all fighting over the same GIL, taking turns instead of running in parallel. This is the trap: threading looks like it should parallelize CPU work, and on almost every other mainstream language it would, but Python’s GIL makes that intuition wrong.
Threading also brings the usual hazards of shared-memory concurrency: race conditions on shared state, and the need for locks (threading.Lock) to protect anything mutable that multiple threads touch.
multiprocessing: real parallelism for CPU-bound work
from multiprocessing import Pool
import time
def cpu_heavy(n):
total = 0
for i in range(n):
total += i * i
return total
if __name__ == "__main__":
start = time.perf_counter()
with Pool(processes=4) as pool:
results = pool.map(cpu_heavy, [20_000_000] * 4)
print(f"multiprocessing: {time.perf_counter() - start:.2f}s")
On a machine with 4+ cores, this genuinely runs close to 4x faster than the sequential version, because each process has its own interpreter and its own GIL, so they truly run in parallel on separate cores.
The cost shows up in a few places:
- Startup overhead: spawning a process is much heavier than spawning a thread, don’t use
multiprocessingfor tiny, frequent tasks, the overhead can dwarf the work itself. - Memory: each process gets its own copy of the Python interpreter and (depending on platform and start method) potentially a copy of your data. This adds up fast for large datasets.
- Serialization: arguments and return values passed between processes get pickled and unpickled. Not everything is picklable (open file handles, database connections, lambdas), and large objects mean real serialization cost.
- The
if __name__ == "__main__":guard is mandatory on platforms that use thespawnstart method (the default on macOS and Windows), because child processes re-import your module from scratch. Skip the guard and you can end up recursively spawning processes.
If your workers need to share state, you don’t get free shared memory like with threads, you need explicit tools: multiprocessing.Value, multiprocessing.Array, multiprocessing.Manager, or just design around passing data in and results out rather than mutating something in common.
asyncio: massive concurrency for I/O, on one thread
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as response:
return response.status
async def main():
urls = ["https://httpbin.org/delay/1"] * 100
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(*(fetch(session, url) for url in urls))
return results
results = asyncio.run(main())
This fetches 100 URLs, each with a 1-second delay, in about 1 second total, not 100. That’s a scale of concurrency threads genuinely struggle with: spinning up 100 OS threads is expensive (each one costs real memory for its stack, plus OS scheduling overhead), but 100 asyncio tasks are cheap, they’re just Python objects managed by the event loop, not OS-level constructs.
The catch: everything in the call chain has to be async-aware. A single blocking call, like using requests instead of aiohttp inside an async function, blocks the entire event loop, not just that one task, because there’s only one thread running everything. This is the most common asyncio mistake: mixing in a synchronous, blocking library and wondering why concurrency disappeared.
# WRONG: this blocks the whole event loop for every request
async def fetch_bad(url):
import requests
return requests.get(url).status_code # synchronous, blocks everything
# RIGHT: use an async-native client, or push blocking calls to a thread
async def fetch_good(url):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, requests.get, url)
asyncio also doesn’t help CPU-bound work at all, a while True: pass inside a coroutine (without an await) freezes the entire event loop, since nothing can interleave with it. Async is for waiting efficiently, not computing faster.
Real-world pattern: mixing models when the workload is mixed
Real programs are rarely purely one or the other. A common and very effective pattern is asyncio for the I/O-bound orchestration, with CPU-bound chunks offloaded to a process pool so they don’t block the event loop:
import asyncio
from concurrent.futures import ProcessPoolExecutor
def resize_image(path: str) -> str:
# CPU-bound work, pretend this uses Pillow
...
return f"{path}.resized"
async def handle_upload(path: str, pool: ProcessPoolExecutor):
loop = asyncio.get_running_loop()
# offload the CPU-bound part so the event loop stays free
resized_path = await loop.run_in_executor(pool, resize_image, path)
return resized_path
async def main(paths: list[str]):
with ProcessPoolExecutor() as pool:
results = await asyncio.gather(*(handle_upload(p, pool) for p in paths))
return results
asyncio.run(main(["a.jpg", "b.jpg", "c.jpg"]))
Here the async layer handles many concurrent uploads cheaply (accepting requests, reading from disk, writing responses), while the actual CPU-heavy resizing runs in worker processes that get real parallelism. Neither tool alone does the whole job well; together they cover both bottlenecks.
Real-world pattern: a quick decision framework
When I’m staring at a slow function and deciding what to reach for, I walk through this:
def pick_concurrency_model(bottleneck: str, scale: str) -> str:
"""
bottleneck: "io" or "cpu"
scale: "few" (dozens) or "many" (hundreds to thousands)
"""
if bottleneck == "cpu":
return "multiprocessing (or a subprocess/native extension, e.g. NumPy)"
if bottleneck == "io" and scale == "many":
return "asyncio"
if bottleneck == "io" and scale == "few":
return "threading (simpler than asyncio if you don't need huge scale)"
return "you probably don't need concurrency yet, profile first"
A few notes behind the logic:
- For a handful of I/O-bound tasks, plain
threadingis often simpler to reason about than rewriting everything withasync/await, especially if you’re calling into libraries that aren’t async-native. - For thousands of concurrent I/O operations (a web server handling many simultaneous connections, a scraper hitting hundreds of endpoints),
asyncioscales in a way threads can’t, thread-per-connection falls over long before task-per-connection does. - For CPU-bound work, don’t reach for threading or asyncio at all, they can’t give you parallelism on the GIL-bound interpreter. Go straight to
multiprocessing, or better yet, see if a C-accelerated library (NumPy, Pillow, pandas) already does the heavy lifting in native code outside the GIL’s reach. - Always profile before adding concurrency of any kind. It’s easy to assume a slow function is I/O-bound when it’s actually spending its time on something CPU-heavy like JSON parsing or regex matching, and vice versa.
Summary
Python’s three concurrency tools aren’t interchangeable, they target different bottlenecks:
threading: real OS threads sharing one GIL. Good for a modest number of I/O-bound tasks. Doesn’t parallelize CPU-bound work because of the GIL. Watch out for shared-state races.multiprocessing: separate processes, each with its own interpreter and GIL. The only one of the three that gives true parallelism for CPU-bound work. Costs more in startup time, memory, and serialization overhead.asyncio: single-threaded cooperative concurrency built aroundawait. Scales to huge numbers of I/O-bound tasks cheaply, but requires async-native libraries end to end, and does nothing for CPU-bound code.- The question that decides which one to reach for is always the same: is the code waiting on I/O, or is it burning CPU? Answer that first, then pick the tool, not the other way around.
- Real systems often combine them,
asynciofor orchestration and I/O,multiprocessingfor the CPU-heavy chunks that would otherwise stall the event loop.