Skip to main content

Processes vs Threads: Units of Work

Introduction

Your web server is getting more traffic than one core can handle, so you decide to run eight copies of your request handler at once. Simple enough — except you now face a fork in the road that every concurrent system in the world has to take:

Do you run those eight copies as eight processes, or eight threads?

It sounds like a trivia question. It is not. That single choice decides how much memory you burn, how fast the copies start, whether they can corrupt each other's data, and — the one that ruins weekends — whether a bug in one of them takes down all of them. PostgreSQL and Chrome answer "processes." Nginx and Redis answer "neither, actually." Go answers "threads, but millions of them." By the end of this lesson you'll understand exactly what each of them is buying.

Here's the whole lesson in one sentence, which we'll spend the rest of it earning: a process is an isolated house; a thread is a housemate. Processes get their own walls and their own everything, and to share anything they have to mail it over. Threads live together in one house — they share the kitchen and the fridge, which makes cooperating effortless and dangerous in equal measure. Get that picture right and every consequence — cost, speed, safety, crash blast radius — falls straight out of it.

Scope: this lesson is about the units of work and what they share. The locks that make shared memory safe get their own lesson (Shared State & Race Conditions); how servers juggle 10,000 connections without 10,000 threads is the lesson right after this one.

Anatomy infographic contrasting processes and threads by their memory. On the left, two processes sit as separate sealed boxes, each a private address space with its own code, global data, heap, file descriptors, and one thread of execution — the wall between them labelled isolation, crossable only by inter-process communication such as pipes, sockets, or shared-memory segments. On the right, one process box holds three threads that all share the same code, global data, and heap, while each thread keeps its own private stack, registers, and program counter; a note on the shared heap shows the threads communicating for free through shared memory, with a warning that two threads writing the same value at once is a race. A unifying banner states that on Linux both fork and pthread_create call the same clone system call and differ only by whether the address space is shared, so a thread is simply a process that agreed to share its memory. A cost strip compares creating a thread at roughly five to ten microseconds against forking a process at roughly thirty-five microseconds, a thread-to-thread context switch near 1.5 microseconds against a process switch near three to five microseconds that also flushes the TLB, and a crash-blast-radius row showing that one thread faulting takes the whole process and all its sibling threads down while a crashing process leaves its siblings alive. A decision row names real systems: PostgreSQL and Chrome choose processes for isolation, nginx and Redis run a few workers with an event loop, and Go multiplexes millions of goroutines onto a few threads.

What a Process Actually Is

When you run a program — open your editor, start your server — the operating system creates a process for it. The word gets thrown around loosely, so let's pin it to the one thing that actually defines it: a process is a private address space.

That address space is the process's own universe of memory. Inside it live everything the program needs: its code, its global variables, its heap (where malloc/new allocations go), and its stack. Crucially, those addresses are virtual — when two processes both read "address 0x400000," the hardware quietly routes each to completely different physical memory. Process A literally cannot name a location inside process B. There is no address it could write down that would reach across.

That's not a limitation the OS forgot to remove — it's the entire point. A process is the operating system's unit of isolation. It's the wall that lets your machine run a browser, a database, and a music player at once without any of them able to scribble on the others' memory, and without a crash in one corrupting the rest. The kernel tracks each process in a bookkeeping record called a Process Control Block (PCB): its ID, its page tables, its memory limits, its open files, its scheduling state — everything needed to pause it, resume it, and keep it walled off from its neighbours.

The price of those walls: if process A wants to share something with process B, it can't just hand over a pointer. It has to use inter-process communication — a pipe, a socket, a shared-memory segment — and usually copy or serialize the data across. Safe, but it costs.

A process = an isolated address space + the resources to run a program. The unit of isolation.

What a Thread Actually Is

Now, a process with all that private memory still needs someone to actually execute the instructions — a point moving through the code, running one line after another. That executor is a thread.

Every process starts with exactly one thread (the one running your main). But you can ask for more, and here's the pivotal fact: the extra threads live inside the same process, sharing its address space. They don't get their own house. They move into the existing one. All of them see the same code, the same global variables, the same heap, the same open files — because it's all literally the same memory.

What each thread does get of its own is the bare minimum it needs to be an independent line of execution:

  • its own stack (its local variables and its chain of function calls),
  • its own CPU registers, and
  • its own program counter (the "which line am I on" pointer).

That's it. Same shared world, private cursor through it. Where a process is the unit of isolation, a thread is the operating system's unit of scheduling — the thing the kernel actually places on a CPU core to run. The kernel gives each thread its own small bookkeeping record, the Thread Control Block (TCB), holding exactly that private context, while all the threads of a process still share the one PCB.

So the difference between a process and a thread isn't really "heavy vs light." It's what they share. A process shares nothing; a thread shares almost everything. Hold that thought, because on Linux it turns out to be literally true in a way that makes the whole distinction click.

The One Idea That Explains Everything

Picture the memory side by side, because this single diagram predicts every consequence in the rest of the lesson.

Two processes: two sealed boxes. Each has its own code, globals, heap, and one thread walking through it. Between the boxes: a wall. To pass anything across, you mail it (IPC).

One process, three threads: one box. Inside it, a single shared code segment, a single shared globals segment, a single shared heap — and then three separate little stacks, one per thread, each with its own cursor. The threads talk to each other by just... reading and writing the shared heap. No mail. No copy. One thread writes x = 42 to a shared object; the other thread reads 42 a nanosecond later. That's the whole magic of threads — communication is free because there's nothing to send; the memory is already shared.

And now the fact that makes it all concrete. On Linux, when you create a process (fork()) and when you create a thread (pthread_create()), you call the exact same underlying system call: clone(). The only difference is a set of flags. A thread is created with flags that say "share the address space, share the file descriptors, share the filesystem context" (CLONE_VM | CLONE_FILES | …). A process is created with those flags off — "give me my own private copy."

A thread is just a process that agreed to share its memory.

That's not a metaphor; it's the same kernel call with different sharing switches. Everything else in this lesson — the costs, the dangers, the design choices — is a downstream consequence of which switches you flipped.

See It: Spawn Threads and Processes Live

Reading about a shared heap is one thing; watching it fill is another. In the lab below, you build the picture: click Add thread and watch the new thread pile into an existing process's shared address space, getting only its own stack. Click Add process and watch a whole new isolated box appear.

Then do the two things that decide real architectures. Hit Crash on a thread and watch the blast radius — the entire process, all its sibling threads, goes down with it. Crash a process and watch its siblings shrug and keep running. And hit Write to heap to see every thread in a process observe the same value instantly — the free communication that also, when two writers collide, is exactly where race conditions are born (that's the next-lessons cliffhanger).

The Spawn Lab: click to add threads or processes and watch the memory model build itself — threads pile into one shared address space (shared heap fills, each gets a private stack), while processes each get a full isolated box. Then hit a crash to see the blast radius (a thread takes its whole process down; a process dies alone), or write to the shared heap and watch every thread in that process see it at once — the live picture of isolation versus sharing.

The Costs, Itemized

"Threads are lightweight, processes are heavy" is the folklore. It's roughly true, but a staff engineer knows the actual numbers and where the cost really hides. Three costs matter:

Creating one. Spawning a thread takes on the order of 5–10 µs; forking a process takes around 35 µs — roughly 7× more. Why the gap? The thread shares the existing address space, so there's almost nothing to set up. fork has to create a new address space — though it cheats with copy-on-write (the child's pages point at the parent's until one of them writes), so it's cheaper than a full memory copy. The catch: the more memory the parent has mapped, the more page-table bookkeeping fork must do, so process creation scales up with your footprint while thread creation stays flat.

Switching between them. When a core switches from one thread to another in the same process, it swaps registers and the stack pointer — about 1.2–1.5 µs. Switching between processes costs more (~2.7–5.5 µs), but the number on the clock isn't the real story: a process switch changes the address space, which flushes the TLB (the cache of virtual→physical address translations) and leaves the CPU caches cold for the newcomer. That cache pollution — paid after the switch, invisibly, as slower memory accesses — is often the bigger bill.

Memory. Each thread gets its own stack — 8 MB of virtual address space by default on Linux. That number scares people into "10,000 threads = 80 GB!" — which is a myth. Stacks are demand-paged: only the pages a thread actually touches become real memory, so a mostly-idle thread costs kilobytes, not megabytes. The true ceiling on thread count is the scheduler and cache pressure, not stack RAM. (Still, people shrink stacks to 512 KB–1 MB when they want to pack many threads.)

CreateContext switchCommunicationCrash blast radius
Thread~5–10 µs~1.5 µsfree (shared heap)kills whole process
Process~35 µs (COW)~3–5 µs (+ TLB flush)IPC (pipe/socket/shm)contained to itself

The Trade-off

Strip away the microseconds and the real decision is a single, symmetric trade-off — the kind this whole course keeps returning to. Neither answer is free; they just bill you on different axes.

Threads pay in safety. Because they share the heap, two threads can touch the same variable at the same time — and when they do, you get a race condition: lost updates, corrupted state, bugs that vanish when you add a print statement. The fix (locks, atomics) costs time and re-introduces the serialization you were trying to escape, plus a fresh way to hang forever (deadlock). Shared memory makes communication free and correctness expensive.

Processes pay in communication. Isolation means no shared variable to race on — but also no shared variable to cooperate through. Every exchange becomes IPC: serialize, send over a pipe or socket, deserialize. Safe and simple to reason about, but slower and more code.

And sitting on top of both is the factor that quietly decides more real architectures than performance ever does: crash blast radius. A thread that dereferences a bad pointer doesn't just kill itself — the fault takes down the entire process, every sibling thread with it. A process that crashes harms only itself; its siblings never notice. When the cost of one component taking down everything is unacceptable, you buy isolation and pay the communication tax on purpose. Which is exactly what the systems you use every day do.

How Real Systems Actually Choose

The best way to make this stick is to see the decision in the wild. Notice that the winners rarely pick "threads" for the obvious speed reason — they pick based on isolation and, increasingly, on avoiding the whole per-connection question entirely.

  • PostgreSQL — one process per connection. A supervisor (postmaster) forks a whole backend process for every client. Heavy, yes — which is precisely why you'll later need a connection pooler in front of it. But it buys bulletproof isolation: one connection crashing or corrupting memory can't take down the others. Postgres chose safety over efficiency, deliberately.
  • Chrome — one process per site. The reason your browser shows twenty processes in Task Manager. A renderer crash or a malicious page is contained to a single site's process; the rest of your tabs live. Chrome spends gigabytes of RAM to buy that blast-radius guarantee.
  • Apache (prefork) — one process per connection. Rock-solid isolation, but 10,000 connections can mean 50–100 GB. Apache's later worker/event modes switch to threads to cut that to a few GB.
  • nginx & Redis — a few workers, no per-connection unit at all. nginx runs roughly one worker process per core, each an event loop handling thousands of connections; Redis famously uses a single main thread. No per-connection process or thread means no per-connection cost, no locks on the hot path, no races. Simplicity as a feature.
  • Go — threads, but millions of them. Go's runtime multiplexes millions of tiny goroutines (each starting at ~2 KB) onto a handful of OS threads. You write thread-style code and get async-style scale.

That last two bullets are the tell: the most scalable systems sidestep the process-vs-thread question by not creating a unit of work per connection at all. That's the door into the next lesson.

A Trap Worth Knowing: Threads Aren't Always Parallel

One concrete gotcha deserves its own moment, because it burns people constantly. It's tempting to think "more threads = more parallelism = faster." Often false — and in one very popular language, guaranteed false for the workload people reach for it.

CPython has a Global Interpreter Lock (GIL): a single lock that lets only one thread execute Python bytecode at a time, even on a 32-core machine. So Python threads give you concurrency — great for I/O-bound work, where threads spend most of their time waiting on the network or disk and can hand off the lock while they wait — but not parallelism for CPU-bound work. Spin up eight threads to crunch numbers and they'll take turns, often slower than one thread because now you're also paying to pass the lock around. For real CPU parallelism in Python you reach for multiprocessing — separate processes, each with its own interpreter and its own GIL.

(The ground is shifting: Python 3.13 shipped an opt-in free-threaded build in late 2024 that removes the GIL, and 3.14 is pushing it toward official support — but it isn't the default yet, and plenty of C extensions still force the lock on.)

The durable lesson underneath the Python specifics: "add threads" is not a synonym for "go faster." Threads are about how work is structured and what it shares, not a magic parallelism dial — a distinction we'll sharpen in Concurrency vs Parallelism.

Mental-Model Corrections

"A thread is a lightweight process." Close, but it hides the point. On Linux a thread is a process that shares memory — same clone() call, different flags. The difference isn't a different species; it's what they share.

"More threads means more speed." Only if the work can actually run in parallel and isn't bottlenecked elsewhere — and under CPython's GIL, CPU-bound threads don't run in parallel at all. Concurrency ≠ parallelism.

"10,000 threads need 80 GB (8 MB each)." No — thread stacks are demand-paged, so idle threads cost kilobytes. The real limit is scheduler and cache pressure, not reserved stack space.

"Processes are heavyweight, always prefer threads." fork is copy-on-write and often cheap, and isolation is frequently worth its price — Postgres and Chrome pick processes on purpose.

"If one thread crashes, only that thread dies." A genuine fault (segfault, unhandled abort) takes the whole process — every sibling thread — down with it. That single fact is why isolation-critical systems reach for processes.

"Sharing memory is the obvious, fast choice." It's fast to communicate, but you inherit races, locks, and deadlocks. You're not skipping the cost — you're moving it from communication to correctness.

Try It Yourself: Watch the GIL Bite

The fastest way to make this permanent is to feel the difference on your own machine. Two quick experiments — write your prediction down first.

1 · Count the threads inside a real process. Every browser tab and database backend is a process with threads inside it. Peek at one (nlwp = number of threads):

# macOS/Linux: pick any running app's PID, then see its thread count
ps -o pid,nlwp,comm -p $(pgrep -n Chrome || pgrep -n chrome)
#   nlwp is the live thread count for that ONE process

2 · Watch Python's GIL erase your parallelism. Predict: on a 4+ core machine, will 4 threads finish a CPU-bound job ~4× faster than 1? Will 4 processes? Run it:

import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def burn(_):            # pure CPU work — no I/O to hide behind
    x = 0
    for i in range(30_000_000): x += i
    return x

def timeit(pool_cls, n=4):
    t = time.time()
    with pool_cls(max_workers=n) as pool:
        list(pool.map(burn, range(n)))
    return time.time() - t

print('4 threads  :', round(timeit(ThreadPoolExecutor), 2), 's')   # ~same as 1 — GIL serializes
print('4 processes:', round(timeit(ProcessPoolExecutor), 2), 's')  # ~4x faster — real parallelism

On standard CPython you'll see the threaded version run about as slow as doing the work once (the GIL lets only one thread execute bytecode at a time), while the process version scales with your cores (each process has its own interpreter and its own GIL). That gap between your prediction and the clock is the whole lesson, measured: threads are about structure and sharing, not a guaranteed speed dial. (On a Python 3.13+ free-threaded build, the thread version speeds up too — proof the GIL, not threading itself, was the ceiling.)

Recap & What's Next

The lesson, compressed:

  • A process is a private address space — the OS's unit of isolation. A thread is a cursor of execution inside it — the OS's unit of scheduling.
  • Threads share code, globals, heap, and file descriptors; each keeps a private stack, registers, and program counter. On Linux both come from the same clone() call — a thread is a process that agreed to share its memory.
  • The trade-off is symmetric: threads pay in safety (shared heap → races → locks), processes pay in communication (isolation → IPC). Riding above both: crash blast radius — a thread fault kills its whole process; a process crash stays contained.
  • Real systems choose by isolation, not just speed: Postgres and Chrome buy processes; nginx, Redis, and Go dodge the per-connection unit entirely.

That last point is the hook. If a thread-per-connection server needs 10,000 threads for 10,000 users — and a process-per-connection one needs 10,000 processes — how does a single nginx worker hold tens of thousands of connections at once on one thread? That's not a rhetorical question; it's the design problem that reshaped how the entire internet handles load. Next up: The 10,000 Connections Problem.