Processes, Threads & Tasks

Thread or Process?

Six dimensions decide it: isolation, memory sharing, startup cost, communication cost, crash impact and access to cores. Operating Systems compares the mechanisms; this lesson is the choice, and the choice is usually made by the crash column rather than the performance one.

▶ Run the lab

The question this answers

The question

For this unit of work, do I want an execution stream that shares everything or one that shares nothing?

The work

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.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

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]].

DimensionThreadsProcessesWhich way it usually points
IsolationNone. 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 sharingFree 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 costTens 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 costA 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 impactFatal 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 coresYes 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
Threads and processes across the six dimensions that decide.

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.

The same malformed PDF under each model. Only the last two steps differ.ILLUSTRATIVE
Invariant · A failure while processing document N affects only document N. Documents 1..N-1 stay indexed and N+1.. stay processable.
#Worker 7 — parsing the malformed PDFWorkers 1–6, 8–20 — parsing fineSupervisor / processState
1·19 documents parsed; results written to the shared index·indexed=19 inFlight=20 alive=yes
2parse doc 7: malformed font table reaches the native decoder··indexed=19 inFlight=20 alive=yes
3native library calls abort() — SIGABRT raised··indexed=19 inFlight=20 alive=yes
4··THREAD MODEL: signal is process-wide → process terminatesindexed=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 unaffectedindexed=19 inFlight=19 alive=yes
6··mark doc 7 poisoned, do not retry, respawn worker 7indexed=19 inFlight=19 alive=yes
7·remaining 19 documents complete normally·indexed=38 inFlight=0 alive=yes
Identical input, identical bug, two completely different blast radii. No try/catch changes the threaded outcome, because a fatal signal has no stack to unwind. When the work invokes code you did not write and cannot prove safe, the address space is the only failure boundary available.

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.

One 40 ms job and one 4 s job under each model. Modelled from typical orders of magnitude.SIMULATED
Threads, short job (ticks ≈ 5 ms)
parse 40 ms
Processes, short job (ticks ≈ 5 ms)
spawn + runtime init + imports (~35 ms)
parse 40 ms
serialise + copy result
Threads, long job (ticks ≈ 500 ms)
parse 4 s
Processes, long job (ticks ≈ 500 ms)
parse 4 s
↑ threads finish; processes still paying overhead on the short job↑ process overhead irrelevant on the long job
runningreadywaitingblockedidlenot linear — the two groups use different scales, marked per lane

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.

How it works
  • 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.
Interleavings that matter
  • 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.
What it guarantees — and does not
  • 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.
Where contention appears
  • 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.
How it fails
  • 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.
When it helps
  • 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.
When it hurts
  • 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]].
How you would know
  • 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.
Complexity it introduces
  • 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]].
Simpler alternatives
  • 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

Concurrency lab
Six knobs, one model. Ask it the only question that matters: does more concurrency help this workload, and what stops it?
SIMULATEDThese numbers describe no real system.

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.

Controls
Cores the process may actually run on. This is the parallelism ceiling.
Threads or tasks in flight. Not the same quantity as cores, and rarely the same number.
Time actually holding a core. This is the only part cores can parallelise.
Waiting while holding no core. This is the part concurrency can hide.
The slice of the CPU work only one task may execute at a time. Clamped to the CPU time.
Offered load. Past capacity the queue has no steady state at all.
Snapshot the current settings, then change one thing. The model is pure, so the “before” column costs nothing to keep.
throughput
150/s
offered 150/s
latency
31 ms
service 30 ms
effective parallelism
2.67
of 4 cores
lock wait
0.0 ms
no critical section
core wait
0.5 ms
queued for a core
switch overhead
0.3 ms
5 switches/task
CPU utilisation38%
Lock utilisation (no critical section)0%
healthy
Retiring 150/s at 38% CPU. Headroom remains; the next constraint appears at about 267/s.
Change one thing · each preset snapshots the current settings first
healthystatus comes from the model’s discriminated result, not from reading the sentence belowSIMULATED

Server model lab

Three server models under the same load
Thread per request, a bounded pool and an event loop, all fed the same requests by the same model.
SIMULATEDOne model, three shapes — not a benchmark of any framework.

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.

Thread per requestunstableworkers=60
One OS thread per in-flight request. Blocking code is allowed to block.
throughput824.7/s
memory124 MB
latencyunbounded
service + queueing
switches/req57
124 MB resident
▲ 60 runnable threads on 4 cores: the scheduler now spends real time moving threads instead of running them, and every thread costs about a megabyte of stack whether or not it is doing anything.
Bounded thread poolunstableworkers=32
A fixed number of threads pull from a queue. Overload becomes queueing, not thread creation.
throughput761.9/s
memory97.2 MB
latencyunbounded
service + queueing
switches/req29
97 MB resident
Event loophealthyworkers=4
One task per core, thousands of tasks in flight. Waiting costs a callback, not a thread.
throughput1428.6/s
memory68.5 MB
latency43 ms
includes I/O wait held off the loop
switches/req0
68 MB resident
At these settings — 60 in flight, 2 ms of CPU, 40 ms of waiting, a 0 ms blocking section — Event loop retires the most work. Change one number and the ranking moves: raise the blocking section and the event loop’s tail explodes while the threads keep being preempted; raise the concurrency and thread-per-request drowns in stacks and switches; drop the concurrency to a handful and all three are indistinguishable, at which point the simplest one wins on the only axis left, which is how hard it is to debug at 3 a.m. No model wins everywhere, and every real runtime you will use is a hybrid of at least two of them.
offered 1,429/s from 60 in flightSIMULATED

More workers than cores

More workers than cores
Four cores, purely CPU-bound tasks, no I/O to hide behind. Add workers and watch what the extra ones buy.
4 cores · 0 ms I/O
1 workerdashed = linear speedup64 workers · max 64.0×
Throughput relative to one worker, 1 → 64 workers. The dashed line is what workers would buy if a worker were a core.
throughput800/s · peak is 800/s at 4 workers
context-switch overhead per task0 · 0.00 ms of every 5 ms task, and it grows with every worker past 4
runnable per core
1.0
CPU utilisation
100.0%
vs. peak
at peak
4 workers on 4 cores: each one has a core to itself, so throughput rises roughly linearly. This is the only region where "add a thread" and "add capacity" mean the same thing. The honest form of the rule: for genuinely CPU-bound work with no waiting, more workers than cores adds overhead, latency variance and memory, and adds no throughput. That is *not* a formula for pool size — this workload has no I/O, no lock and no memory-bandwidth ceiling. Add any of those and the useful worker count moves, sometimes far above the core count. Size a pool from measurement of the real workload, not from a rule of thumb.
SIMULATEDContext switching modelled as a flat cost per switch. Real cost depends on cache and TLB footprint and is usually worse — and never better — than this.

What people believe, and what is true

Claim

Threads are lighter, so threads by default.

Reality

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%.

Claim

We catch exceptions, so a bad document cannot take down the service.

Reality

A segfault or an abort() in a native library is not an exception. Only the address space boundary contains it.

Claim

Processes are safer, full stop.

Reality

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.

Apply it