Threading vs Multiprocessing
Threads share memory and excel at I/O-bound work but the GIL blocks CPU parallelism; processes each get their own GIL for true multi-core speed — at the cost of pickling and IPC. `concurrent.futures` lets you switch between them in one word.
Learning objectives
- › Classify a workload as CPU-bound or I/O-bound and pick the right concurrency model
- › Use concurrent.futures to swap ThreadPoolExecutor for ProcessPoolExecutor in one word
- › Explain the pickling and start-method constraints that come with multiprocessing
- › Protect shared state in threads with locks and recognise a race condition
Prerequisites
Version notes
- · macOS switched its default multiprocessing start method to `spawn` in Python 3.8; Windows has only ever supported `spawn`.
- · Python 3.14 changes the default start method on Linux (and other Unix except macOS) from `fork` to `forkserver`, because forking a multithreaded process is unsafe — a DeprecationWarning was added in 3.12.
- · `ProcessPoolExecutor` gained `max_tasks_per_child` in Python 3.11 to recycle workers and cap memory growth.
Simple explanation
What it is. Python gives you three concurrency models. Threads live inside one process and share memory, but the GIL lets only one run Python bytecode at a time. Processes each get their own interpreter, memory space, and GIL, so they run bytecode on many cores simultaneously. asyncio uses a single thread that cooperatively switches between tasks at `await` points. Choosing between them comes down to one question: is your work CPU-bound or I/O-bound?
Why it exists. There is no single 'fast' concurrency primitive — the right choice depends on where the program spends its time. Code waiting on the network is limited by latency, not the CPU, so overlapping the waits (threads or asyncio) is enough. Code crunching numbers is limited by CPU cycles, and only spreading it across cores (processes) makes it faster. Pick the wrong model and you get all the complexity of concurrency with none of the speed-up.
The problem it solves. It gives you a decision rule and the trade-offs behind it: threads and asyncio overlap I/O cheaply but cannot parallelise pure-Python CPU work, while processes achieve true multi-core parallelism at the price of process startup, duplicated memory, and pickling every argument and result across the boundary.
How it works. `concurrent.futures` unifies threads and processes behind one `Executor` interface — `ThreadPoolExecutor` and `ProcessPoolExecutor` are interchangeable, so you can literally change one word to switch models. Under the hood threads share the same objects (so a read-modify-write on shared state needs a `Lock`), while processes serialise data with `pickle` and communicate over pipes. Each worker process is created by a *start method* — `fork`, `spawn`, or `forkserver` — which controls how the child interpreter is born.
Analogy: Threads are cooks sharing one kitchen: cheap to add, but they keep queuing at the single stove (the GIL) and can grab the same knife at the same moment (a race) unless they take turns (a lock). Processes are separate food trucks: each has its own kitchen and can cook in true parallel, but handing an ingredient between trucks means packing it up, driving it over, and unpacking it (pickling plus IPC).
Common misconceptions
- "More threads means more speed." — For CPU-bound Python code the GIL serialises execution, so extra threads add overhead without parallelism; only processes (or GIL-releasing C code) speed CPU work up.
- "Multiprocessing is always faster than threading." — Process startup, memory duplication, and pickling can cost more than the work itself. For small tasks or I/O-bound work, processes are usually slower than threads.
- "Processes share memory like threads do." — Each process has its own address space. Data crosses the boundary only by being pickled and copied (or via an explicit shared-memory object), so a global mutated in a child is invisible to the parent.
- "asyncio makes CPU-bound code faster." — It is single-threaded cooperative I/O; one long computation blocks the whole event loop. Offload CPU work to a process pool.
Common alternatives
- asyncio for very high I/O concurrency (thousands of sockets) without a thread's memory per connection
- NumPy / native extensions that release the GIL for CPU-heavy numeric loops
- joblib or Dask for higher-level parallelism across many cores or machines
- The free-threaded (no-GIL) 3.13+ build, where threads can parallelise CPU work
Interactive visualization
1import threading, time23def download(url):4 time.sleep(1) # I/O wait -> GIL is RELEASED5 print("done", url)67threads = [threading.Thread(target=download, args=(u,))8 for u in ("a", "b", "c")]9for t in threads: t.start()10for t in threads: t.join()
Threads (one GIL)
Three threads start
Only one thread can hold the GIL and run bytecode. T-a gets it; the others wait for the GIL.
Code examples
Note: During time.sleep (a stand-in for network I/O) the GIL is released, so the four calls overlap instead of running one after another. pool.map returns results in input order, so the output is deterministic.
Behind the scenes
- ▸`concurrent.futures` exposes one `Executor` interface; `ThreadPoolExecutor` and `ProcessPoolExecutor` differ only in whether workers are threads (shared memory) or processes (separate interpreters), so switching models is a one-word change.
- ▸Process pools move arguments and return values across the boundary with `pickle`; unpicklable values (lambdas, locally-defined functions, open sockets, DB connections) raise `PicklingError` when you submit them.
- ▸Start methods control how a worker is born: `fork` clones the parent's memory (fast, Unix-only), `spawn` boots a fresh interpreter and re-imports your module (why the `if __name__ == '__main__'` guard is required), and `forkserver` forks children from a clean single-threaded server.
- ▸`fork` copies memory but not the parent's other threads or the locks they hold, so forking a multithreaded process can deadlock — the reason Python 3.14 makes `forkserver` the Linux default.
- ▸The GIL makes a single bytecode atomic but not a compound statement: `x += 1` is LOAD/ADD/STORE, so two threads can interleave and lose an update — which is why shared mutable state needs a `Lock`.
Interview insights
Key definitions
- · Thread: an execution context inside a single process that shares the process's memory; CPython threads must hold the GIL to run bytecode.
- · Process: an independent interpreter instance with its own memory and its own GIL, giving true multi-core parallelism at the cost of inter-process communication.
- · Race condition: a bug whose outcome depends on the nondeterministic interleaving of threads that touch shared state without synchronisation.
How do you choose between threading, multiprocessing, and asyncio?
Concise answer: Ask whether the work is CPU-bound or I/O-bound. CPU-bound -> multiprocessing (each process has its own GIL, so it uses many cores). I/O-bound -> threads or asyncio (the GIL is released during waits, so they overlap). Reach for asyncio when the concurrency is very high (thousands of sockets).
Detailed answer: The GIL lets only one thread run Python bytecode at a time, so threads cannot parallelise CPU-bound Python — use processes, which each own a GIL and a memory space and run on separate cores. For I/O-bound work the GIL is released during the wait, so threads overlap nicely; asyncio does the same on a single thread with far less per-task memory, making it the better fit for very high I/O fan-out. concurrent.futures lets you prototype with ThreadPoolExecutor and switch to ProcessPoolExecutor by changing one word.
Common mistake: Reaching for multiprocessing on I/O-bound work, where process startup and pickling cost more than the work they save.
Follow-ups:
- · What is the overhead of a process versus a thread?
- · When would you still avoid asyncio for I/O work?
You switched a ThreadPoolExecutor to a ProcessPoolExecutor and got a PicklingError (or the whole script re-ran). What happened?
Concise answer: Processes don't share memory, so arguments and results are pickled and sent over a pipe; anything unpicklable (a lambda, a local function, an open connection) fails. And with spawn/forkserver each child re-imports your module, so top-level launch code must sit under if __name__ == '__main__' or it runs again in every worker.
Detailed answer: A ProcessPoolExecutor serialises each task's callable and arguments with pickle and rebuilds them in the child. Lambdas, closures, locally-defined functions, sockets, and DB handles can't be pickled, so define worker functions at module top level and pass plain data. Because spawn (Windows/macOS, and Linux from 3.14 via forkserver) starts a fresh interpreter that imports your module, unguarded top-level code executes in every child — put the launch logic under if __name__ == '__main__'.
Common mistake: Passing a lambda or a bound method carrying unpicklable state as the worker function.
Follow-ups:
- · Which start method avoids the re-import? (fork)
- · How do you share large read-only data without pickling it repeatedly?
Two threads increment a shared integer and the final total is wrong. Why, and how do you fix it?
Concise answer: counter += 1 compiles to load, add, store — three bytecodes. The GIL can switch threads between them, so two threads read the same value and one increment is lost. Guard the read-modify-write with a threading.Lock, or funnel work through a thread-safe queue.Queue.
Detailed answer: The GIL makes individual bytecodes atomic but not compound operations. x += 1 is a LOAD_FAST / BINARY_OP / STORE_FAST sequence; a thread switch between the load and the store means both threads start from the same value and one update vanishes — a data race, so the total comes out below what you expect and varies run to run. Wrap the critical section in a Lock (with lock:) so only one thread updates at a time, or push work through a thread-safe queue.Queue and aggregate in one place.
Common mistake: Assuming the GIL already makes += thread-safe.
Follow-ups:
- · What is the difference between a Lock and an RLock?
- · How could you avoid shared mutable state entirely?
Knowledge check
You need to speed up a CPU-bound function across 8 cores. Which gives real parallelism?
You must issue 500 concurrent HTTP requests. Which fits best with the least memory?
Two threads each run `counter += 1` one million times on a shared counter with no lock. The final value is:
Score: 0/3