50 · Processes vs threads: the mental model
Understand the difference between a process and a thread, why CPython's GIL makes threads useless for CPU-bound work (but perfect for I/O), and how to choose the right tool before writing a single line of concurrent code.
A **process** is a fully isolated program with its own memory — expensive to create, safe to parallelize. A **thread** shares memory inside a process — cheap to create, but CPython's Global Interpreter Lock (GIL) ensures only one thread runs Python bytecode at a time. This makes threads ideal for **I/O-bound** work (waiting on network, disk, or database) but useless for CPU-bound work (crunching numbers). Choosing the wrong abstraction costs you hours of debugging with zero performance gain.
Without this:
Without understanding processes vs threads and the GIL, you end up using `threading` to parallelize a matrix multiplication loop and wonder why it's *slower* than sequential code — or you reach for `multiprocessing` for a web scraper and burn CPU forking dozens of heavy processes for work that a few threads would handle trivially.
Two units of execution
Before writing a single line of concurrent Python you need two vocabulary words nailed down:
Process
A process is an independent program instance. The operating system gives it:
- Its own virtual address space — memory is completely isolated from every other process.
- Its own Python interpreter — including module imports, global variables, and open file handles.
- A real OS PID —
os.getpid()shows you the number.
Creating a new process is expensive (~30–100 ms on Linux, more on Windows). Because memory is isolated, sharing data between processes requires explicit serialization through pipes, queues, or shared memory segments. The payoff: true CPU parallelism — two processes can run Python bytecode simultaneously on two CPU cores.
Thread
A thread (also called a thread of execution) lives inside a process. Several threads share the same memory, the same module imports, and the same heap. Creating a thread is cheap (~0.1 ms). The killer caveat in CPython: the Global Interpreter Lock.
The GIL — one sentence
CPython holds a global mutex called the GIL while executing Python bytecode, so only one thread can run Python bytecode at any instant, even on a multi-core machine.
This means threading gives you concurrency (tasks interleave) but not parallelism (tasks run simultaneously on separate cores). For I/O-bound work — where a thread spends most of its time waiting for a network response or disk read — this is fine: the waiting thread releases the GIL, and another thread runs. For CPU-bound work — tight loops, array math — the GIL turns threading into a sequential bottleneck.
PEP 703 — the future
PEP 703 ("Making the Global Interpreter Lock Optional") was accepted for CPython 3.13 as an experimental build flag. Over the 3.13–3.15 window, the no-GIL build is becoming mainstream. For now (3.10–3.12, which most production stacks use) the GIL is still fully in force.
Latency vs throughput
- I/O-bound — latency dominates. You spend time waiting, not computing. Threads shine here because a waiting thread releases the GIL.
- CPU-bound — throughput dominates. You spend time computing. The GIL makes threads useless; use
multiprocessingor numpy/C-extension vectorisation (both release the GIL at the C level).
Two processes have fully isolated heaps — no shared state, no GIL problem, real parallelism. Two threads in one process share the heap (great for communication, dangerous without locks) and fight over the GIL (only one runs Python at a time).
Python (in browser)
Run this and you'll see `thread count: 1` — just the main thread. Every Python program starts here. All the machinery you'll build in the next three lessons grows from this single-thread baseline.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Pin this decision tree to your wall. The most common mistake: reaching for `threading` to speed up CPU math. It doesn't — the GIL ensures that. Profile first (`cProfile`, `line_profiler`), then pick the right tool.
You need to crunch 10 million floating-point numbers using a pure Python loop. Will `threading.Thread` speed this up?
- A **process** has isolated memory and a full Python interpreter — expensive to create but capable of true CPU parallelism. A **thread** shares memory inside a process — cheap to create but bound by the GIL in CPython.
- The **GIL** (Global Interpreter Lock) ensures only one thread runs Python bytecode at a time. It is released during I/O waits and C-extension computation, so threads ARE useful for I/O-bound work.
- Rule: **threads for I/O-bound** (network, disk, DB), **processes for CPU-bound** (number crunching, image processing, feature extraction).
- PEP 703 introduces a no-GIL CPython build (3.13+) but mainstream production stacks (3.10–3.12) still have the GIL. Code accordingly.
- Every Python program starts with exactly one thread (the main thread). You can inspect this with `threading.enumerate()`.
Data loading is I/O-bound — reading images from disk, downloading batches from object storage — making it a perfect thread workload. PyTorch's `DataLoader(num_workers=4)` spawns 4 worker threads (or processes) to prefetch batches while the GPU is busy with the current batch. The actual numpy/tensor math is C-level and releases the GIL, so multiple DataLoader workers CAN run in parallel. Cross-validation fold evaluation is CPU-bound Python and benefits from `multiprocessing`. Grid search (`GridSearchCV(n_jobs=-1)`) uses processes under the hood via `joblib`.
If you remove it: Without the process/thread mental model you'll misapply concurrency constantly — threading a CPU-bound training loop (no speedup, extra complexity), or forking a web scraper into 20 processes (30× the memory for work 4 threads could do). The GIL is the single most misunderstood fact about CPython, and getting it wrong leads to hours of profiling a "parallel" program that runs identically to sequential.