The question this answers
For this unit of work, do I want an execution stream that shares everything or one that shares nothing?
A document ingestion pipeline: 20 concurrent jobs, each parsing a user-uploaded PDF with a native library, extracting text, and writing results into a shared in-memory index used by search queries on the same box.
Under threads: the search index, the parser's global configuration, the logger, and the entire heap whether you meant it or not. Under processes: nothing in memory, and the index must become a service, a file or a message stream.
Every accepted document is either fully indexed or recorded as failed, and one malformed PDF must never cause a second document's results to be lost or corrupted.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Six dimensions, and which of them actually decides
Most of this table is well known and rarely decisive. Startup cost matters only when units are short. Communication cost matters only when they are chatty. Memory sharing is a convenience most of the time and a liability the rest of the time.
The row that decides real designs is crash impact. A thread has no failure boundary: an unhandled native fault, a stack overflow in a C extension, an abort() in a decoder, or an out-of-memory condition ends the *process*, taking every other thread's in-flight work with it. Twenty concurrent documents become twenty failures because one PDF had a malformed font table. A process boundary turns that into one failure and a non-zero exit code the supervisor can act on.
The other decisive row is the last one, and it is runtime-specific rather than universal: in some runtimes threads simply cannot execute your code on more than one core at a time, which converts "threads or processes" from a trade-off into a constraint. See [[python-threads-vs-processes]].
| Dimension | Threads | Processes | Which way it usually points |
|---|---|---|---|
| Isolation | None. One address space; every thread can reach every object. | Complete for memory. Nothing reachable without an explicit mechanism. | Processes, whenever the code is untrusted, native, or historically crashy |
| Memory sharing | Free and implicit — pass a reference and it is shared, with no copy and no ceremony. | Explicit only: pipes, sockets, or a shared-memory segment you asked for. Everything else is serialised. | Threads, when a large mutable structure genuinely must be shared |
| Startup cost | Tens of microseconds. Cheap enough that pools exist mainly to bound count, not to amortise creation. | Tens of milliseconds, plus runtime init, imports, JIT warm-up and connection setup. | Threads for short units; irrelevant for units measured in seconds |
| Communication cost | A pointer. Effectively free, and dangerous for exactly that reason. | Serialise, copy through the kernel, deserialise. Microseconds to milliseconds by size. | Threads for chatty fine-grained work; processes for coarse hand-offs |
| Crash impact | Fatal to the whole process. Every thread's in-flight work is lost with no unwind. | Contained. The supervisor sees the exit status and decides what to do with that one job. | Processes — this is the row that decides most real designs |
| Access to cores | Yes in C++, Java, Go, Rust and Node worker threads. Not for bytecode execution in standard CPython builds. | Yes, always, in every runtime — separate interpreters, separate everything. | Runtime-dependent, and a hard constraint rather than a preference where it binds |
One malformed PDF, two architectures
The schedule below runs the same failure through both models. The native parser hits a malformed font table and calls abort(). Under threads, the process dies: nineteen documents that were fine are lost, the in-memory index built since the last checkpoint is gone, and the search endpoint on the same process goes down with it. Under processes, exit code 134 arrives at the supervisor, one job is marked poisoned, and nothing else notices.
Two details are worth pulling out. First, no exception handler saves the threaded version — abort() and segfaults are not exceptions, and there is no stack to unwind. The only boundary that contains them is the address space. Second, the loss is not just the in-flight work: it is every piece of process-local state that had not been persisted, which under threads is exactly the shared structure that made threads attractive.
This is why crash impact usually outranks performance in the decision. The threaded version might be 20% faster on a good day. The process version does not have a bad day where nineteen unrelated jobs disappear.
| # | Worker 7 — parsing the malformed PDF | Workers 1–6, 8–20 — parsing fine | Supervisor / process | State |
|---|---|---|---|---|
| 1 | · | 19 documents parsed; results written to the shared index | · | indexed=19 inFlight=20 alive=yes |
| 2 | parse doc 7: malformed font table reaches the native decoder | · | · | indexed=19 inFlight=20 alive=yes |
| 3 | native library calls abort() — SIGABRT raised | · | · | indexed=19 inFlight=20 alive=yes |
| 4 | · | · | THREAD MODEL: signal is process-wide → process terminates | indexed=0 inFlight=0 alive=no ✕ Nineteen unrelated documents lost, the unpersisted index gone, and the search endpoint on this process down — all from one bad input to document 7. |
| 5 | · | · | PROCESS MODEL: child 7 exits 134; children 1–6, 8–20 unaffected | indexed=19 inFlight=19 alive=yes |
| 6 | · | · | mark doc 7 poisoned, do not retry, respawn worker 7 | indexed=19 inFlight=19 alive=yes |
| 7 | · | remaining 19 documents complete normally | · | indexed=38 inFlight=0 alive=yes |
What the cost difference actually looks like
The performance argument for threads is real and much smaller than it sounds, because it is amortised over the unit of work. Spawning a thread is on the order of tens of microseconds; spawning a process with a runtime, imports and a connection pool is on the order of tens of milliseconds. Against a 40 ms document parse that is a 50% overhead and threads clearly win. Against a 4-second parse it is 1% and the argument evaporates.
The same amortisation applies to communication. Handing a parsed document to the index is a pointer under threads and a serialise-copy-deserialise under processes. For a 2 MB extracted text blob that is a few milliseconds — negligible once, ruinous if the workers exchange state continuously.
The honest summary: use threads when units are short and chatty and the code cannot crash; use processes when units are long, self-contained, or invoke code you do not trust. And when the answer is "both", use a pool of processes each running a few threads, which is what most production ingestion pipelines actually are.
Key points
- Six dimensions: isolation, memory sharing, startup cost, communication cost, crash impact, access to cores.
- Crash impact is usually the deciding row. A fatal signal in one thread ends every thread's work; a process boundary contains it.
- No exception handler saves a threaded process from
abort()or a segfault — there is no stack to unwind. - Startup and communication costs are amortised over the unit of work: decisive for 40 ms jobs, irrelevant for 4-second ones.
- Threads share by pointer, which is free and is exactly why unintended sharing is so easy.
- Access to cores is runtime-specific and is a hard constraint, not a preference, where it binds.
- The common production answer is both: a pool of processes for containment, a few threads inside each for cheap local concurrency.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • A thread is created inside the existing address space, sharing page tables, descriptors and globals, with only a new stack and register set. See
[[threads-intro]]. - • A process is created with its own page tables and its own runtime, and inherits only what the parent explicitly passes. See
[[process-creation]]. - • Communication between threads is a memory write plus whatever synchronisation makes it visible; between processes it is serialise, copy through the kernel, deserialise.
- • A fatal signal is delivered to the process, not to a thread, so it terminates every thread in it.
- • A supervisor observes child exit status and applies policy — retry, poison, respawn, back off — which is a capability the thread model does not offer at all.
- • Threads: worker 3 and worker 11 write the shared index simultaneously without a lock — a data race whose symptom is a corrupted index rather than a crash, discovered by a search query returning nonsense.
- • Threads: worker 7 aborts; all twenty in-flight jobs die together, and the ones that had completed but not yet been persisted die too.
- • Processes: worker 7 aborts; the other nineteen are unaffected and the supervisor marks doc 7 poisoned.
- • Processes: workers 3 and 11 both write results for the same document because the dispatcher retried; the index gains duplicates unless writes are keyed idempotently.
- • Hybrid: a process crashes with four of its own threads in flight, losing exactly those four — which is the blast radius you chose when you picked threads-per-process.
- • Threads guarantee shared visibility of memory given proper synchronisation, and cheap communication. They guarantee no fault isolation whatsoever.
- • Processes guarantee memory isolation and that a crash is observable and contained. They guarantee nothing about shared files, rows, keys or ports.
- • Neither guarantees ordering between units, and neither makes any shared external resource safe.
- • A supervisor guarantees you can *observe* a child's death. It does not guarantee the work is safe to retry — that requires idempotency you designed in. See
[[idempotency]]. - • Copy-on-write at fork makes the child's memory cheap only until it is written; a garbage collector touching every page defeats it entirely.
- • Threads contend for locks on the shared index and for the cache lines underneath it.
- • Processes contend for machine memory — twenty runtimes instead of one — and for external limits such as the database connection cap.
- • Both contend for cores; only the process model guarantees it can use them in every runtime.
- • The process model adds contention on the dispatch channel, whose full state is backpressure and is therefore useful rather than merely costly.
- • Threads: a fatal signal or an out-of-memory kill destroys every in-flight unit and every piece of unpersisted process state.
- • Threads: data races and lost updates on the shared index, silently corrupting results with no crash at all.
- • Processes: orphaned children after a supervisor crash, still holding connections and writing output.
- • Processes: startup cost dominating throughput when units are short, so the pool spends its time initialising.
- • Processes: memory exhaustion from N runtimes, with the OOM killer choosing the largest worker mid-job.
- • Both: double-processing of a unit after a crash-and-retry, unless the write side is idempotent.
- • Choose threads when units are short and numerous, the shared structure is large and mutable, and the code is entirely yours.
- • Choose processes when units invoke native or untrusted code, when they are long enough to amortise startup, or when the runtime denies threads access to cores.
- • Choose both when the workload has both shapes: containment at the process level, cheap concurrency inside.
- • Threads hurt whenever a single input can be fatal, because the blast radius is every concurrent unit.
- • Processes hurt when the work is chatty or fine-grained, where serialisation exceeds the work.
- • Processes hurt under memory pressure, particularly in containers where the limit is per-container and enforcement is a kill rather than a slowdown.
- • Threads hurt at very high concurrency counts, where a stack each becomes the binding constraint. See
[[tasks-vs-threads]].
- • Unit duration against spawn cost. Below roughly 10× the spawn cost, the process model is spending a visible fraction of its time starting up.
- • Bytes serialised per unit against bytes of actual work — the direct measure of whether the boundary is in the right place.
- • Process exit-code distribution, which quantifies how often containment is actually being used.
- • Blast radius per incident: units lost per failure. This is the number the crash-impact row is really about, and it is measurable after the fact.
- • Total RSS against the container limit, since the process model's memory cost is linear in worker count.
- • Threads add invariants and locks to every shared structure, plus a lock ordering once there is more than one.
- • Processes add a supervisor, a serialisation format, a restart policy and cross-process correlation in the logs.
- • A hybrid adds both, and a two-level capacity question — how many processes, how many threads each — where the product must fit the machine.
- • Cancellation differs: a thread can often be asked to stop cooperatively, a process is signalled, and neither is instantaneous. See
[[cancellation]].
- • Neither: a single-threaded process per core behind a load balancer, which gets core utilisation and containment with no in-process concurrency at all.
- • A managed worker service or job runner that already implements supervision, retries and poison handling. See
[[background-jobs]]. - • Separate deployments communicating over a queue, when the units are independent enough that they need not be children of anything.
- • Sandboxes — WebAssembly, a container per job, a seccomp-restricted subprocess — when the requirement is security rather than only crash containment.
Concurrency lab
They come from a queueing and contention model inside Engineer Atlas. What is faithful is the behaviour: work that waits benefits from more workers, work that computes does not, a wide critical section pins parallelism near 1 no matter how many cores you buy, and arrivals past capacity produce an unbounded queue rather than a large latency. Real arrivals are burstier than this model assumes, so real systems reach every one of these walls earlier than the sliders suggest. Do not quote a millisecond from this page.
Server model lab
Each model is the same simulator given a different worker shape: a thread per in-flight request, a fixed pool, or one task per core where waiting does not occupy a worker. Memory is a per-thread stack estimate. Real servers differ by orders of magnitude in all of these, and every runtime has its own hybrids. Concurrency in flight is the knob; offered load is derived from it as concurrency ÷ service time.
More workers than cores
What people believe, and what is true
Threads are lighter, so threads by default.
Lighter is decided by unit duration. Against a 4-second job the process overhead is under 1%, and the containment it buys is worth far more than 1%.
We catch exceptions, so a bad document cannot take down the service.
A segfault or an abort() in a native library is not an exception. Only the address space boundary contains it.
Processes are safer, full stop.
Safer in memory. Every process still writes the same rows, files and keys, and the check-then-act race lives there untouched. See [[processes]].
Go deeper
Overview
Threads share memory and die together. Processes share nothing and die alone. Everything else is startup and communication cost, amortised over how long the work takes.
Practical
Ask two questions: can this code take the process down, and is the unit long enough that tens of milliseconds of startup do not matter. Two yeses mean processes and the rest is detail.
Advanced
The real decision is where you want the failure boundary, and boundaries are also deployment and scaling boundaries. A process boundary you chose for crash containment is the same boundary you will later use to move that work to its own machine — which is why it tends to be the more durable choice.