Threads and processes
The OS-level distinction underneath every Python concurrency decision. One sentence carries it: processes do not share memory and threads do. Everything else — cost, communication, what a crash takes down — follows.
What each one is
A process is a program with its own address space and its own file handles. The kernel gives it resources and isolates it: nothing it writes is visible to another process unless you deliberately arrange it.
A thread is a unit of execution inside a process. Several threads share one address space, so a variable written by one is immediately visible to the others, and they need somewhere to run rather than a whole environment.
import os, threading
os.getpid() # same for every thread in this process
threading.get_ident() # different per threadThat single call pair is the whole model: one process id, many thread ids.
What follows from sharing memory
| Processes | Threads | |
|---|---|---|
| Memory | isolated | shared |
| Creation cost | high | low |
| Communication | IPC, serialised | direct, a variable |
| A crash takes down | itself | the whole process |
| Needs locks | for shared files, memory, DB rows | yes |
Read that table in one direction and it argues for threads; read it in the other and it argues for processes. The trade is isolation against cost, and which side you want depends on whether the work needs to share state.
Communication is the practical difference
Threads pass data by assigning to a variable. Processes cannot — the other address space has no such variable — so anything crossing the boundary must be serialised, copied, and deserialised.
# Threads: this is just memory.
results.append(row)
# Processes: this is pickle, a pipe, and unpickle.
queue.put(row)That serialisation is why a process pool can be slower than doing the work inline: if the arguments and results are large, the copying costs more than the computation saved. It is also why anything unpicklable — a lambda, an open socket, a local class — cannot cross. See ProcessPoolExecutor: what processes add.
Isolation cuts both ways
A crashing thread can corrupt shared state or take the whole process with it. A crashing process takes only itself, which is why process-per-request models were popular long before anyone was worried about the GIL.
What each one costs
Measured on CPython 3.14.5, Windows:
| Unit | Start it | Hold 10,000 idle |
|---|---|---|
| asyncio task | 9 us | +8 MB |
| thread | 151 us | +341 MB |
| subinterpreter | 16.5 ms | not tested |
process, spawn |
573 ms | not sensible |
A thread reserves a stack of address space (megabytes) but touches only tens
of kilobytes, which is why 10,000 idle threads cost 341 MB here rather than
gigabytes. Processes under spawn start a fresh interpreter and re-import the
main module; fork and forkserver on Linux are much cheaper to start, but
each process still carries its own interpreter and imports in memory.
Moving data between processes
A 100 MB bytes object took 35 ms to pickle and unpickle, and that is paid
again for every worker and every result. For large read-only data,
multiprocessing.shared_memory costs one copy in and lets readers attach
without copying. Better still, pass a reference (a file path, an object key, a
row range) and let the worker load what it needs.
What a failure takes down
| Failure | Threads or asyncio | Process pool |
|---|---|---|
| C-extension segfault | the whole process dies | one worker dies: BrokenProcessPool |
| unhandled exception | that thread or task | that worker’s future |
A process pool survives a crashing extension; a thread pool and an event loop
do not. An exception in a thread is printed by threading.excepthook and
otherwise lost unless something reads the result; in asyncio it waits on the
Task until someone awaits it, and is logged as “Task exception was never
retrieved” if nobody does.
How they combine in a real service
A FastAPI service under uvicorn is all three at once: N worker processes,
each running one event loop, each with a thread pool for sync
endpoints and to_thread calls. Resources multiply accordingly: a database
pool of 10 per process across 8 processes is 80 connections, before any
Celery workers. Size pools per process, and count processes when you talk to
the DBA.
Why this matters more in Python than elsewhere
In most languages threads give you parallelism and the decision is about isolation. In CPython the GIL means threads do not run Python bytecode in parallel, so processes are also the answer to a question — CPU-bound work — that threads answer elsewhere.
That is changing: free-threaded builds are official in 3.14, which makes threads a real parallelism option for the first time. The trade above is unaffected — shared memory still means locks, isolation still costs serialisation — but the reason for choosing processes narrows. See The GIL, and free-threaded Python and CPU-bound versus I/O-bound.
Related
Interview angle 5
- “What is the difference between a thread and a process?” - a process has its own address space; threads share one inside a process. That single fact gives you the rest: threads are cheap to create and communicate through ordinary variables, processes are isolated and have to serialise anything they exchange.
- “When would you pay for processes?” - when you need isolation or real parallelism. A crash in one process does not touch the others, and separate interpreters mean separate GILs. The bill is creation cost and serialising every argument and result across the boundary.
- “Why is a process pool sometimes slower than not using one?” - because the arguments and results are pickled, copied through a pipe and unpickled. If the payload is large relative to the computation, that overhead exceeds what the parallelism saved.
- “Why does this distinction matter more in Python?” - because the GIL means
threads do not execute Python bytecode in parallel, so processes answer a
question - CPU-bound work - that threads answer in most other languages.
Free-threaded builds being official in 3.14 narrows that, without changing
the underlying isolation-versus-cost trade.
- Follow-up: “So why not just use threads on 3.14t?” - extension readiness (one unsupported module re-enables the GIL), a thread-safety audit of code that relied on the GIL making races rare, and a 5-10% single-thread penalty. Processes stay the lower-risk default for now.
- “What does each model cost to start?” - on 3.14.5 under Windows: an
asyncio task 9 us, a thread 151 us, a subinterpreter 16.5 ms, a spawned
process 573 ms. Ten thousand idle threads added 341 MB, ten thousand idle
tasks 8 MB.
- Follow-up: “A C extension segfaults. What survives?” - in a process pool, the other workers; with threads or asyncio, nothing: the whole process dies.