Processes, Threads & Tasks

Processes: Isolation You Cannot Accidentally Break

Separate address spaces, explicit communication, contained crashes. The kernel mechanics live in Operating Systems; what matters here is the design consequence — a process model makes shared mutable state impossible by construction and makes every piece of sharing you actually need visible in the code.

▶ Run the lab

The question this answers

The question

What does running work in separate processes actually buy me, and what does it stop being able to protect?

The work

A thumbnail service running an image decoder with a native C dependency, 40 jobs per second, where a malformed file can and does segfault the decoder.

What is shared

Nothing in memory, by construction — no worker can reach another's heap. What is shared is everything outside memory: the input directory, the output bucket, the job table, the log file, and the operating system's file descriptors and ports.

The invariant — what must stay true under every interleaving

Each job produces exactly one output object and exactly one terminal status row, and a crash while processing job N leaves jobs 1..N-1 completed and jobs N+1.. still processable.

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?

Two address spaces, and a hole you drilled on purpose

A process owns an address space. A pointer in worker A means nothing in worker B — not "means something dangerous", but genuinely nothing, because the hardware translates it through a different page table. Operating Systems covers how in [[process-anatomy]], [[process-isolation]] and [[process-memory-layout]]. The design consequence is the one that matters here: shared mutable memory is not something you must be disciplined about avoiding, it is something you cannot do by accident.

That inverts where the effort goes. Under threads, sharing is the default and every piece of it must be found and protected. Under processes, isolation is the default and every piece of sharing must be built — a pipe, a socket, a queue, a shared-memory segment you asked for explicitly. See [[ipc-overview]] and [[shared-memory]].

This is why "no shared state" is nearly true of a process model and never quite true. Every hole is visible in the code, which is the entire benefit — you can enumerate them in a review. The trap is that the enumeration usually stops at IPC and forgets the filesystem, the database and the object store, which are shared by all of them and protected by none of it.

Two workers, two address spaces, three deliberate holes
unreachable from p2unreachable from p1dispatch jobdeserialisedeserialiseopt-in — no isolation hereopt-in — no isolation hereSHARED — isolation does not applySHARED — isolation does not applySHAREDSHAREDobserves exit codeobserves exit codeSupervisor processPipe / socketpair — bytes, serialisedWorker process 1Worker process 2heap + globals (private)Shared memory segment — explicit, unprotectedFilesystem + object storeJob tableheap + globals (private)
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The isolation is real, and it is exactly this wide

language-specific· CPython 3.12, multiprocessing. Start method matters: fork (Linux default before 3.14) copies the parent; spawn (macOS and Windows default) re-imports the module in a fresh interpreter.

The demonstration people find surprising is the simplest one: a module-level variable mutated in a child is not mutated in the parent. Not "eventually", not "with a delay" — never. The child got a copy of the address space at fork time, or a fresh interpreter at spawn time, and the two have nothing to do with one another afterwards.

Everything you want to share has to be an object built for it — a queue, a value in a shared-memory block, a manager proxy — and every one of those costs serialisation on each access. A shared counter through a manager proxy is a round trip to another process; ten million of them will be several orders of magnitude slower than an in-memory increment. Fine-grained sharing is what a process model is bad at, and that is a design constraint rather than a tuning problem.

The start-mode caveat matters and bites people: with fork the child inherits a copy of everything including open descriptors and lock states; with spawn it gets a fresh interpreter that re-imports your module. Code that works under one can deadlock or double-execute under the other, and the default differs by platform and by version.

1import multiprocessing as mp
2
3counter = 0 # module-level global
4
5def bump(n):
6 global counter
7 for _ in range(n):
8 counter += 1 # mutates THIS process's copy
9 return counter # returned by value, serialised back
10
11if __name__ == "__main__":
12 with mp.Pool(4) as pool:
13 results = pool.map(bump, [1000] * 4)
14
15 print(results) # [1000, 1000, 1000, 1000] -- four separate copies
16 print(counter) # 0 -- the parent's global was never touched
17
18 # To actually share, you must ask for it, and then you own the invariant:
19 shared = mp.Value("i", 0) # one integer in a shared memory segment
20 def bump_shared(_):
21 with shared.get_lock(): # NOT optional: shared.value += 1 is
22 shared.value += 1 # read-modify-write, and processes race too
23 # Isolation removed shared MEMORY. It did not remove shared FILES,
24 # shared DATABASE ROWS or shared OBJECT KEYS -- see the schedule below.
CPython 3.12: the child's globals are the child's. There is no accidental sharing to find.

What process isolation does not isolate

The failure teams hit after moving to processes is always the same one, and it is the one the model quietly encourages: having removed shared memory, everyone stops thinking about shared state. But the two workers still write to the same bucket, still update the same job row, and still append to the same log.

The schedule below is a check-then-act race across two processes with no shared memory whatsoever. Both workers claim job 4471 because both read its status before either wrote it. The output object is written twice — harmless, since it is the same key — and the billing row is written twice, which is not. Separate address spaces contributed nothing, because the contended state was never in an address space.

The fixes are the ones from the database side of the world rather than the concurrency-primitive side: a conditional update that claims the row atomically (UPDATE ... WHERE status = 'pending' and check the affected-row count), a unique constraint, or a queue with visibility timeouts that does the claiming for you. [[database-concurrency]] and [[optimistic-concurrency-control]] are the relevant lessons, and [[local-lock-not-distributed]] is the trap of reaching for an in-process mutex here.

Two worker processes, zero shared memory, one double-billed job.ILLUSTRATIVE
Invariant · Each job row transitions pending → running exactly once, and produces exactly one billing record.
#Worker process 1 (pid 8801)Worker process 2 (pid 8802)State
1SELECT status FROM jobs WHERE id=4471 → pending·jobs.4471=pending billingRows=0
2·SELECT status FROM jobs WHERE id=4471 → pendingjobs.4471=pending billingRows=0
3UPDATE jobs SET status='running', worker=8801 WHERE id=4471·jobs.4471=running/8801 billingRows=0
4·UPDATE jobs SET status='running', worker=8802 WHERE id=4471jobs.4471=running/8802 billingRows=0
5decode + PUT thumbs/4471.jpg·jobs.4471=running/8802 billingRows=0
6INSERT billing (job 4471, 1 unit)·jobs.4471=running/8802 billingRows=1
7·decode + PUT thumbs/4471.jpgjobs.4471=running/8802 billingRows=1
8·INSERT billing (job 4471, 1 unit)jobs.4471=running/8802 billingRows=2
✕ Job 4471 produced two billing records. Both processes claimed a row neither had exclusively, and neither has any memory the other could have corrupted.
The customer is billed twice for one thumbnail. Process isolation prevented every in-memory failure mode and prevented none of this, because the shared state was a database row. The fix is an atomic claim — UPDATE jobs SET status='running' WHERE id=4471 AND status='pending', then act only if one row was affected — or a unique constraint on (job_id) in the billing table so the second insert fails loudly.

Key points

  • A process owns an address space; another process's pointers are meaningless in it. Shared mutable memory becomes impossible rather than merely discouraged.
  • Every piece of sharing must be built explicitly — a pipe, a socket, a shared-memory segment — which makes the sharing enumerable in a code review.
  • Crash containment is the headline feature: a segfault, an OOM or a native-library abort kills one worker and the supervisor reaps it.
  • The cost is serialisation on every exchange, and startup in the tens of milliseconds rather than the tens of microseconds.
  • Memory is multiplied by the worker count — separate heaps, separate runtimes, separate caches — and copy-on-write only helps until the pages are written.
  • Isolation covers memory and nothing else. Files, database rows, object keys, ports and locks in the filesystem are shared by every worker.
  • Explicitly shared memory (a Value, a mmap segment) has exactly the same race problems as threads, with none of the language's help.

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
  • The supervisor creates workers — fork then exec, or a fresh spawn — each receiving its own page tables and its own runtime. See [[process-creation]].
  • Work is dispatched by serialising a job descriptor onto a pipe or socket; the worker deserialises it into its own heap.
  • The worker executes with no reference to any other worker's memory, so its only failure surface is its own address space plus the external resources it opens.
  • Results are serialised back, or written directly to shared external storage and acknowledged by reference.
  • On a crash the kernel tears down the address space and the supervisor observes the exit status — signal 11 for a segfault, 137 for an OOM kill — and decides to retry, mark the job poisoned, or restart the worker.
Interleavings that matter
  • P1 and P2 process disjoint jobs concurrently on separate cores: no shared memory, no locks, no interleaving to reason about — the model at its best.
  • P1 segfaults mid-decode on a malformed file; P2 is unaffected and completes normally; the supervisor sees exit code 139 and marks job 4471 poisoned. Under threads, the same segfault takes down every in-flight job in the process.
  • P1 and P2 both read job 4471 as pending, both claim it, both bill for it — the schedule above, with no shared memory involved anywhere.
  • P1 crashes after writing the output object and before writing the status row; a retry re-does the work and writes the object again. Idempotent by key, so harmless — which is the property that makes crash-and-retry safe at all. See [[idempotency]].
  • Both processes hold a Value counter and increment it without taking get_lock(): read 41, read 41, write 42, write 42. Explicit shared memory reintroduces the exact lost-update race the model was supposed to prevent.
What it guarantees — and does not
  • The kernel guarantees one process cannot read or write another's memory without an explicit, permissioned mechanism.
  • It guarantees a crashing process is torn down completely, releasing its memory and its file descriptors, and that the parent can observe how it died.
  • It does not guarantee isolation of anything outside memory: files, sockets, database rows and object keys are shared and unprotected.
  • It does not guarantee the child is a faithful copy of the parent. Under spawn the module is re-imported; under fork in a multi-threaded parent, only the calling thread survives, which is how a child inherits a mutex that is locked forever.
  • It does not guarantee ordering between workers, and it does not make explicitly shared memory safe — a Value needs a lock exactly as a global would.
Where contention appears
  • Contention moves outside the process: the database, the object store, the log file, the connection limit. Sixteen workers times ten connections is a hundred and sixty connections.
  • The dispatch pipe is a contention point at high job rates, and its buffer is a bounded queue whose full state is backpressure.
  • Memory is contended at the machine level: sixteen workers each with a 300 MB runtime is 4.8 GB before any work is done.
  • Explicitly shared memory is contended exactly like threaded shared memory, including false sharing on the same cache lines.
How it fails
  • Orphaned children: the supervisor dies and the workers keep running, holding connections and writing output nobody expects.
  • Zombie accumulation when exit statuses are never reaped, exhausting the process table.
  • Double-claim races on external shared state, as in the schedule — the characteristic bug of a process model.
  • Fork-in-a-threaded-parent deadlock: the child inherits a mutex that was held by a thread that does not exist in the child, and the first allocation hangs forever.
  • IPC dominance: fine-grained sharing through a manager proxy, where serialisation costs orders of magnitude more than the work.
  • Memory exhaustion from worker count multiplied by per-worker footprint, with the OOM killer selecting the largest worker mid-job.
When it helps
  • Untrusted or crash-prone code: native decoders, PDF parsers, user-supplied scripts, anything where a segfault is a realistic Tuesday.
  • Runtimes where threads cannot execute your code on separate cores, making processes the only path to CPU parallelism. See [[python-threads-vs-processes]].
  • Long-running coarse-grained jobs where a 30 ms startup and a serialisation round trip round to nothing against 90 seconds of work.
  • Memory-leak containment: a worker recycled every N jobs bounds a leak you have not found yet, which is an unglamorous and extremely effective production tactic.
  • When you want the option of moving a worker to another machine later — a process boundary is already the shape of that move.
When it hurts
  • Fine-grained work: thousands of small tasks per second, where serialisation and startup dominate.
  • Large shared read-only data: every worker gets its own copy, and 2 GB of reference data times eight workers is 16 GB. Copy-on-write helps until a garbage collector touches the pages.
  • Chatty coordination: workers that must frequently agree on shared state spend their time in IPC rather than in work.
  • Tight memory budgets, especially in containers where the limit is per-container and the OOM killer is per-cgroup. See [[resource-envelope]].
How you would know
  • Child exit codes and their distribution — signal 11, signal 9, non-zero returns. This is the direct evidence that isolation is doing work rather than merely costing money.
  • Worker restart rate over time; a rising rate is a poison-input problem or a leak, and the two look identical until you correlate with job ids.
  • Time from dispatch to first byte of work, which isolates serialisation and startup cost from the work itself.
  • Total RSS across workers against the container limit, with a margin, because the OOM kill is a cliff rather than a slope.
  • IPC bytes per job. If it is comparable to the work being done, the process boundary is in the wrong place.
Complexity it introduces
  • Everything shared must be serialisable, which constrains what a job descriptor can contain — no open connections, no callbacks, no live handles.
  • You now operate a supervisor: spawn, health, restart policy, backoff, graceful shutdown and reaping are all yours to write or configure.
  • Debugging spans processes: a stack trace covers one address space, and correlating across workers needs job ids threaded through every log line.
  • Configuration multiplies: pool size, per-worker memory limits, restart thresholds and IPC buffer sizes, each with its own failure mode.
  • Startup and warm-up costs are paid per worker — JIT warm-up, connection pools, caches — which is why worker recycling has to be tuned rather than set to 1.
Simpler alternatives
  • Threads, when the code is trusted, the work is fine-grained, and the runtime can actually use cores. See [[threads]] and [[thread-vs-process]].
  • One process with a bounded worker pool inside it, if crash containment is not a real requirement — most business logic does not segfault.
  • Containers or sandboxes when the isolation requirement is about security rather than crashes; a process boundary is not a security boundary on its own. See [[containers-and-the-os]].
  • A queue plus separate deployments, when the workers do not need to be children at all — this gets isolation, independent scaling and independent deploys for the same conceptual price.

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

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

What people believe, and what is true

Claim

Processes have no shared state, so there are no races.

Reality

They have no shared *memory*. The job table, the object store, the log file and the filesystem are shared by every worker, and the classic check-then-act race lives there.

Claim

Processes are just expensive threads.

Reality

They buy crash containment and, in some runtimes, the only access to multiple cores. For a 90-second job the extra 30 ms of startup is a rounding error and the containment is the product.

Claim

A process boundary is a security boundary.

Reality

It is a memory boundary. Without namespaces, seccomp, a distinct uid and filesystem restrictions, a compromised worker still reaches everything the user can reach.

Go deeper

Overview

Each process gets its own memory. Nothing leaks between them by accident, everything shared must be sent explicitly, and one crashing does not take down the others.

Practical

Use processes when code can crash, when threads cannot use cores, or when jobs are coarse enough that serialisation is noise. Then go and find the state that is still shared: rows, files, keys.

Advanced

A process model relocates concurrency risk rather than removing it. In-memory races become impossible and external-resource races become the only kind, which is a good trade because external stores offer atomic primitives — conditional updates, unique constraints, leases — that shared memory does not.

Apply it