Free lesson · Concurrency Interview Mastery
Threading vs Multiprocessing
The GIL lesson set up a decision that every Python developer faces: when your program is too slow and you want concurrency, do you reach for threading or multiprocessing? They look similar - both run code "at the same time" - but they're built on fundamentally different foundations, and choosing wrong means either no speedup or unnecessary overhead. This lesson makes the choice mechanical: threading for I/O-bound, multiprocessing for CPU-bound, and here's exactly why and how.
The core difference
Threading runs multiple threads inside one process, sharing memory, constrained by one GIL - so no parallel Python execution. Multiprocessing runs multiple separate processes, each with its own Python interpreter and its own GIL, with isolated memory - so they achieve true parallelism across cores.
flowchart TD
subgraph TH["threading (1 process)"]
G[Single GIL]
TT1[Thread 1] --> G
TT2[Thread 2] --> G
TT3[Thread 3] --> G
SM[(Shared Memory)]
TT1 --- SM
TT2 --- SM
end
subgraph MP["multiprocessing (N processes)"]
P1[Process 1<br/>own GIL · own memory] --> Core1[Core 1]
P2[Process 2<br/>own GIL · own memory] --> Core2[Core 2]
P3[Process 3<br/>own GIL · own memory] --> Core3[Core 3]
end
This diagram contrasts threading and multiprocessing. On the left, threading runs three threads inside a single process. They all share one GIL and share memory, so only one thread runs Python at a time. On the right, multiprocessing runs three separate processes, each with its own GIL and its own isolated memory, each able to run on a different core - process one on core one, process two on core two, process three on core three. So multiprocessing achieves true parallel execution across cores, while threading does not.
That one structural difference drives everything: threading shares memory cheaply but can't parallelize CPU work; multiprocessing parallelizes CPU work but pays for isolation with communication overhead.
The multiprocessing module
The API deliberately mirrors threading, so it feels familiar:
from multiprocessing import Process
def worker(name):
print(f"Computing in process {name}")
if __name__ == "__main__": # REQUIRED guard - see the gotcha below
p = Process(target=worker, args=("A",))
p.start()
p.join()
For parallelizing work across many inputs, Pool is the workhorse - it spreads tasks across a pool of processes, one per core typically, and collects results:
from multiprocessing import Pool
def square(n):
return n * n
if __name__ == "__main__":
with Pool(processes=4) as pool:
results = pool.map(square, [1, 2, 3, 4, 5]) # runs in parallel across 4 processes
print(results) # [1, 4, 9, 16, 25]
pool.map is the clean parallel-map pattern: hand it a function and an iterable, and it computes them in parallel and returns results in order. This is the bread-and-butter way to use multiple cores for CPU-bound Python work.
The cost: processes aren't free
Processes buy true parallelism, but you pay for it in three ways, and a strong engineer weighs these before reaching for multiprocessing:
Creation overhead. Starting a process is much heavier than starting a thread - the OS sets up a whole new interpreter and memory space. For tiny, short tasks, this overhead can exceed the work itself.
No shared memory by default. Each process has its own isolated memory, so they can't just read each other's variables. To share data you must use explicit inter-process communication, which copies data across the process boundary.
Pickling. To send data between processes (arguments to a worker, results back), Python serializes it using pickle. This has two consequences: it costs time for large data, and not everything is picklable - lambdas, open file handles, some closures, and certain objects can't be sent, causing errors.
Warning: The most common multiprocessing bug for beginners: forgetting the
if __name__ == "__main__":guard. On Windows and macOS (which use the "spawn" start method), each child process re-imports your main module to set itself up. Without the guard, that re-import re-runs your process-creating code, which spawns more processes, which re-import and spawn more - an infinite explosion of processes. Always guard the entry point. This is a guaranteed interview-adjacent gotcha and a real production crash.
Inter-process communication (IPC)
Since processes don't share memory, the multiprocessing module provides explicit channels to pass data:
Queue- a process-safe queue; the most common way to pass messages between processes (producer-consumer across processes).Pipe- a two-way connection between exactly two processes.Value/Array- shared-memory primitives for a single value or array that processes can share directly (with a lock), avoiding copying for simple shared state.Manager- provides shared, higher-level objects (dicts, lists) that multiple processes can access, coordinated by a manager process (more flexible, more overhead).
from multiprocessing import Process, Queue
def producer(q):
q.put("result")
if __name__ == "__main__":
q = Queue()
p = Process(target=producer, args=(q,))
p.start()
print(q.get()) # receive across the process boundary
p.join()
Tip: Prefer message passing (
Queue/Pipe) over shared memory (Value/Array) unless you specifically need shared state, and preferPool.mapover hand-managingProcessobjects when you're parallelizing a function over many inputs. The high-level tools (Pool,Queue) handle the tricky parts correctly; reaching straight for low-level shared memory invites bugs. This mirrors Module 1's lesson: reducing shared mutable state - even across processes - eliminates whole classes of problems.
Start methods
A detail that bites in cross-platform code: processes can be created three ways. fork (default on Linux) copies the parent process - fast, but inherits everything, which can cause subtle bugs. spawn (default on Windows and macOS) starts a fresh interpreter and re-imports - slower and safer, and the reason the __main__ guard is required. forkserver is a middle option. You can set this explicitly with multiprocessing.set_start_method(...). Knowing spawn re-imports your module explains the guard requirement and several platform-specific surprises.
A concrete benchmark
Here's the comparison that makes the choice undeniable - the same CPU-bound task run both ways:
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def heavy(n):
total = 0
for i in range(n):
total += i * i # pure CPU work
return total
work = [10_000_000] * 4 # four heavy tasks
# Threads: serialized by the GIL - roughly same as sequential
start = time.time()
with ThreadPoolExecutor() as ex:
list(ex.map(heavy, work))
print(f"threads: {time.time() - start:.1f}s") # no real speedup
# Processes: true parallelism across cores
if __name__ == "__main__":
start = time.time()
with ProcessPoolExecutor() as ex:
list(ex.map(heavy, work))
print(f"processes: {time.time() - start:.1f}s") # ~4x faster on 4+ cores
On a multi-core machine the process version finishes roughly four times faster (four tasks on four cores), while the thread version is no better than running the tasks one after another. The reason is exactly the GIL: the threads can't run Python computation in parallel, but the processes each have their own GIL and their own core. Run this and the numbers settle the threading-vs-multiprocessing debate for CPU-bound work permanently in your mind.
The high-level interface: ProcessPoolExecutor
I used ProcessPoolExecutor above rather than raw Pool. That's concurrent.futures - the clean, unified interface over both threads and processes, and the one you should usually reach for. The elegant thing about it is that switching between threading and multiprocessing is a one-word change:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# I/O-bound: use threads
with ThreadPoolExecutor(max_workers=10) as ex:
results = list(ex.map(io_task, items))
# CPU-bound: change ThreadPoolExecutor -> ProcessPoolExecutor. That's it.
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(cpu_task, items))
Same API, same submit/map/Future model - only the executor class differs. This is so useful it gets its own lesson next; for now, note that it lets you express "run these concurrently" once and choose threads-vs-processes by swapping one class. It's the idiomatic modern way to do both kinds of Python concurrency.
A decision walkthrough
To make the rule reflexive, three quick scenarios:
- Scraping 1,000 web pages. Each page is a network wait - I/O-bound. Use threads (or asyncio); the GIL releases during each request so they overlap. Multiprocessing would work but wastes memory and pickling overhead on a problem threads solve cheaply.
- Resizing 1,000 images / heavy numeric processing in pure Python. Each task is CPU-bound. Use multiprocessing - separate processes on separate cores give true parallelism. Threads would serialize on the GIL and gain nothing.
- Numeric work using NumPy. Special case - NumPy releases the GIL during its C-level computation, so threads can actually parallelize NumPy-heavy work. Here threading may suffice even though it's "CPU-bound," because the heavy lifting escapes the GIL. Knowing this exception is a strong signal.
Tip: The decision tree in one breath: waiting → threads/asyncio; computing in pure Python → processes; computing in NumPy/C → threads can work because those release the GIL. That third branch separates people who memorized "CPU means processes" from people who understand why - it's not about CPU-bound per se, it's about whether the work holds the GIL.
The decision, made mechanical
| threading | multiprocessing | |
|---|---|---|
| Runs in | One process, many threads | Many processes |
| GIL | One shared - no parallel Python | One per process - true parallelism |
| Memory | Shared (cheap communication) | Isolated (must use IPC) |
| Best for | I/O-bound work | CPU-bound work |
| Creation cost | Low | High |
| Data passing | Direct (shared vars) | Pickled across boundary |
| Main risk | Races on shared state | Overhead, pickling limits, __main__ guard |
The rule: Is your bottleneck waiting or computing? Waiting (network, disk, database) → threading (or asyncio); the GIL releases during I/O so threads overlap. Computing (math, data crunching in Python) → multiprocessing; separate processes use separate cores. If you remember nothing else: threads for I/O, processes for CPU.
Interview: The defining question: "When would you use threading vs multiprocessing in Python?" Answer with the reasoning, not just the rule: "It depends on whether the work is I/O-bound or CPU-bound. For I/O-bound work - network calls, file or DB access - I'd use threading, because the GIL is released during blocking I/O, so threads make progress concurrently. For CPU-bound work - heavy computation - threading can't help because of the GIL, so I'd use multiprocessing, where each process has its own interpreter and GIL and runs on a separate core for true parallelism. The tradeoff is that multiprocessing has higher overhead and needs IPC since memory isn't shared, plus data must be picklable." Then the bonus: "For I/O-bound at very high concurrency, I might prefer asyncio over threads to avoid thread overhead." That full answer demonstrates you understand the why, which is exactly what the question is testing.
When the line blurs: mixed and nested workloads
Real programs aren't always cleanly I/O-bound or CPU-bound, and the most sophisticated answer recognizes that you can combine the models when a workload has both characters. Consider a pipeline that downloads many files (I/O-bound) and then runs heavy processing on each (CPU-bound). The naive approach picks one tool and suffers on the other half. The better approach matches each stage to its nature: use threading or asyncio to fetch the files concurrently, filling the network waiting time, then hand the downloaded data to a process pool to crunch it in parallel across cores. The download stage wants concurrency over waiting; the processing stage wants parallelism over computing; and there's no rule that says one program must use only one model.
A common production pattern makes this concrete. A web service handles incoming requests with threads or async (each request is mostly I/O - waiting on databases and downstream services), but when a request needs genuine heavy computation, it offloads that specific work to a separate process pool so it doesn't block the request-handling layer and so it can use multiple cores. The request layer is I/O-bound and uses threads or asyncio; the compute layer is CPU-bound and uses processes. Being able to describe this layered design - different concurrency models at different layers of the same system - is a strong senior signal, because it shows you understand the tools as complementary rather than as an either-or choice.
A note on sharing state across processes
One more practical wrinkle that trips people up when they move from threading to multiprocessing. With threads, sharing state is trivial - the threads share memory, so a shared object is just there (with locks for safety). With processes, that intuition breaks: each process has its own memory, so a global variable in the parent is not automatically visible to or shared with the children. Beginners frequently write multiprocessing code that mutates a shared list or dictionary and are baffled when the parent never sees the changes - because each process was modifying its own private copy.
The fixes, in rough order of preference, are: pass data explicitly as arguments and return results (the cleanest, message-passing style); use multiprocessing.Queue to send results back; or, only when you truly need shared mutable state, use the explicit shared-memory tools (Value, Array) or a Manager. The general principle from Module 1 applies with extra force here: across processes, reducing shared mutable state isn't just safer, it's often the only thing that works without surprising you, because the isolation is real and enforced by the operating system. Design your multiprocessing code to communicate results rather than to share variables, and an entire category of "why didn't my change show up" confusion disappears.
Interview: A subtle follow-up worth anticipating: "Why not just always use multiprocessing to be safe - it works for both, right?" It technically runs for I/O-bound work too, but it's the wrong default there for concrete reasons: processes carry far higher memory cost (a full interpreter each), startup is slower, and every argument and result must be pickled and copied across the process boundary, which adds latency and rules out unpicklable objects. For I/O-bound work, threads (or asyncio) achieve the same overlapping of waiting time with a fraction of the overhead and with trivial data sharing. Reaching for processes when threads suffice is over-engineering that costs memory and speed. Matching the tool to the bottleneck - not defaulting to the heaviest option - is exactly the judgment the question is checking for, and naming the pickling and memory costs shows you understand why the lighter tool is correct, not just that it's lighter.
Note: Keep in mind that "threading vs multiprocessing" isn't the whole map - asyncio (next-lesson territory) is a third option for I/O-bound work that often scales better than threads at very high connection counts, and
concurrent.futuresgives you a single interface over both threads and processes so you can switch with a one-line change. The decision in this lesson is the foundational one, but in practice you're choosing among three models, and the right answer is whichever matches your bottleneck - waiting versus computing - with the least overhead.
Quick reference
# I/O-bound -> threading (GIL releases on I/O)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor() as ex: ex.map(io_task, items)
# CPU-bound -> multiprocessing (true parallelism)
from multiprocessing import Pool
if __name__ == "__main__": # REQUIRED guard
with Pool() as pool:
pool.map(cpu_task, items)
# IPC: Queue (messages), Pipe (2 procs), Value/Array (shared mem), Manager (shared objects)
# Gotchas: pickling required; not everything is picklable; guard __main__
# RULE: threads for I/O, processes for CPU
Next, the third Python concurrency model - and arguably the most modern for I/O - asyncio: how a single thread can juggle thousands of concurrent operations with no threads and no GIL contention at all.