What a process actually is, why it's the only way to get real CPU parallelism in Python, and why isolated memory makes it feel nothing like threading β before you touch a single pattern.
A process is an operating-system-level program with its own private memory
space, its own Python interpreter, and β crucially β its own GIL. When you
start a Python program, the OS gives you exactly one process. multiprocessing lets you
spawn more, and each one is a fully independent Python running in parallel.
One reason, and it's a big one: true CPU parallelism. Threads and asyncio
both run Python bytecode on a single core at a time because of the GIL. If your bottleneck is
the CPU β crunching numbers, parsing, compressing, running a model β the only way in the standard
library to use all your cores is to run multiple processes, each with its own GIL.
from concurrent.futures import ProcessPoolExecutor
import time
def burn(_):
# pure-Python CPU work β the GIL cannot be released here
total = 0
for i in range(30_000_000):
total += i
return total
if __name__ == "__main__": # REQUIRED β see Pattern 7
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(burn, range(4)))
print(f"done in {time.perf_counter() - start:.1f}s") # β 1s on 4 cores, not 4s
Processes don't share memory, so everything you send to a child and every result it returns is
serialized with pickle, copied across an OS boundary, and rebuilt on the
other side. That has three consequences you'll feel constantly:
if __name__ == "__main__"
On macOS and Windows, Python starts child processes with the spawn method:
a brand-new interpreter that re-imports your module to rebuild the worker. If your process-
launching code runs at module top level, each child re-runs it on import β spawning children that
spawn children, forever.
import multiprocessing as mp
def worker():
print("working")
# β WITHOUT the guard, on spawn platforms: infinite process explosion / RuntimeError
if __name__ == "__main__": # β
this block only runs in the ORIGINAL process
p = mp.Process(target=worker)
p.start()
p.join()
RuntimeError: An attempt has been made to start a new process beforeβ¦" bug is a missing
__main__ guard. There's a whole guide on start
methods (Pattern 7) β but internalize the guard before anything else.
Process β create, start(), join() a single process. The low-level primitive. (Pattern 1)ProcessPoolExecutor β the modern, high-level way to run many tasks and get results back as Futures. Reach for this first. (Pattern 2)Queue / Pipe β pass messages between processes, since you can't share variables. (Pattern 3)Value / Array / shared_memory / Manager β the escape hatches for actually sharing state without copying. (Pattern 4)Pool β the classic worker pool with map/imap/starmap. (Pattern 5)ProcessPoolExecutor (or
Pool) first. Raw Process + Queue is worth understanding, but the
pool handles spawning, result collection, and cleanup for you.
| Your bottleneck is⦠| Use | Why |
|---|---|---|
| CPU (pure-Python number crunching) | processes | Only way past the GIL β a GIL per process |
| A handful of blocking I/O calls (sync libs) | threads | Shared memory, cheap, GIL frees on I/O |
| Thousands of concurrent I/O tasks | async | Scales huge for almost no memory |
A process is an independent Python with its own memory and its own GIL, so multiple
processes genuinely run in parallel across cores β the one thing threads and async
can't do. The price is isolation: nothing is shared, so arguments and results are
pickled and copied, startup is costly, and some objects can't travel at all. You
create workers with Process or (better) a ProcessPoolExecutor, move data with
queues, pipes, or shared memory, and always guard the launch with
if __name__ == "__main__". Use processes when the CPU is your bottleneck;
reach for threads or async when you're just waiting.