Skip to content
Software Atlas

Threads vs Processes

Separate houses or roommates? How the OS isolates memory, schedules threads and pays for context switches — how Node and Go use both — and how real systems like Chrome, Postgres and MySQL pick a side.

⏱ ~40 min read14 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

Every program you run — your editor, a Node server, a Go binary — runs inside a process. Inside each process, one or more threads do the actual work. "Should this be another process or another thread?" is one of the oldest trade-offs in software, and you meet it every time you scale a server.

In one sentenceA process is a running program with its own private memory. A thread is a worker inside a process, sharing that memory with the process's other threads.
Two ways to run three workers
3 processes (separate houses)
🏠 PID 4120
A
🧊 own memory
🏠 PID 4121
B
🧊 own memory
🏠 PID 4122
C
🧊 own memory
1 process, 3 threads (roommates)
🏠 PID 4120
ABC
🧊 one shared memory
Left: each process has its own memory (🧊 the fridge) — nobody can touch anyone else's. Right: three threads share one process's memory — instant sharing, and instant trouble if one misbehaves.
1

Isolation vs sharing

Processes are walled off from each other: safe, but sharing data means copying it. Threads share everything: fast, but one bug can hurt them all.
2

The OS runs threads

The OS hands CPU time to threads, a few milliseconds at a time. Every swap from one thread to another (a context switch) costs a little.
3

Node and Go differ

Node runs your JavaScript on one main thread and offers worker threads and child processes for the rest. Go runs thousands of goroutines on a few threads, all in one process.

Here's the whole trade-off on one screen. Each row gets its own section below.

ProcessThread
MemoryIts own, private (§02)Shares its process's memory (§03)
If it crashesOnly that process diesThe whole process dies, every thread with it (§03)
Switching between themSlightly more expensive (§05)Slightly cheaper (§05)
Creating oneSlower: roughly a millisecond or more (§06)Faster: microseconds
Talking to othersThrough the OS: pipes, sockets — data is copied (§09)Just read the same variable — but needs a Lock (§03)
Famous usersChrome (a process per site), Postgres (a process per connection)MySQL (a thread per connection), Java servers (§10)
Foundations§02

A process: one program, its own memory

In one sentenceA process is a running program plus everything the OS gave it: an ID, a private memory space, open files, and at least one thread.

A program is a file on disk, like /usr/bin/node or your compiled Go binary. A process is that program running. Run node server.js in two terminals and you get two processes from the same program — two houses built from one blueprint.

What the OS gives every process

When a process starts, the Operating system (OS) / kernel sets up a little kit for it and keeps a record of it in its process table:

  • An ID. A number called the PID, e.g. 4120, plus the PID of its parent — the process that started it (often your shell).
  • Its own memory. Room for the program's code, its global variables, its Heap, and a Call stack for each thread. Private to this process — more on that below.
  • Open files and connections. Every file and network Socket it opens gets a small number, a File descriptor.
  • Its surroundings. Environment variables, the current directory, and which user it runs as (which decides what it's allowed to do).
  • At least one thread — the main thread — to run the code. It can start more (§03).

You can ask a program for its own PID:

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("my PID:", os.Getpid())      // e.g. 4120
	fmt.Println("parent PID:", os.Getppid()) // the shell that started me
}

And from the outside, ps lists processes. On Linux you can also ask how many threads each one has:

Linux
$ ps -o pid,ppid,nlwp,rss,comm -p 4120,4188
  PID  PPID NLWP   RSS COMMAND
 4120  3301   11 48212 node
 4188  3301    8 12480 api-server

PID/PPID = the process and its parent. NLWP = number of threads (Linux calls them "lightweight processes"). RSS = RAM in use, in KB. COMMAND = the program. Yes — the "single-threaded" Node process has about 10 threads; §07 explains who they are.

Each process lives in its own address space

Memory is one long row of numbered bytes; a byte's number is its address. Programmers usually write addresses in hexadecimal (base 16), like 0x5000 — the 0x just means "this number is in hex".

Here's the trick: every process gets its own private numbering. Two processes can both store something at address 0x5000 and never collide. Each process sees its own Address space, as if it were the only program on the machine.

This is Virtual memory. The addresses your program uses are virtual: on every memory access, the CPU translates them into real RAM locations using a lookup table the OS keeps per process, the Page table.

Same address, different memory
Process A · PID 4120Process B · PID 4121physical RAM0x1000code0x5000heap: name = "Ada"0x7ff0stack0x1000code0x5000heap: name = "Bob"0x7ff0stackcode (shared)B's pagefreeA's pageB's pageA's page
Both processes use the address 0x5000 for their heap, but each one's page table sends it to a different spot in physical RAM. Process A has no address that leads into B's memory. (Here both processes run the same program, so the kernel lets them share its code page: code is read-only, so sharing is safe.)

That's where the walls come from. Process A can only use addresses in its own page table, and none of them lead to B's memory. A can't read B's data, can't overwrite it — it can't even name it.

What if a program uses an address that isn't in its page table at all — say, a null pointer (address 0)? The CPU refuses, and the OS kills the process. That's the famous Segmentation fault. (That's what happens in C. JavaScript checks for null first and throws a TypeError, and Go turns a nil pointer into a panic, so you rarely see a real segfault in either.)

Foundations§03

Threads: roommates sharing one house

In one sentenceA thread is one line of execution inside a process. All the threads of a process share its memory and open files; each thread keeps only its own stack and registers.

Every process starts with one Thread, the main thread. The program can ask the OS for more. Each new thread runs its own function, side by side with the others, inside the same house.

Shared vs private

Shared by every thread in the processOne per thread (not protected)
The program's codeIts own Call stack (local variables, which function it's in)
Global variables and the HeapIts own registers, including the program counter (where it is in the code)
Open files and socketsIts thread ID
The PID, the user, the environment—

Why is the stack private? Because each thread is in the middle of different function calls. Thread A might be three calls deep in handleRequest, thread B in writeLog; each needs its own pile of plates. Everything else lives in the one address space they share.

"One per thread" doesn't mean protected: each thread has its own stack, but it sits in the same address space, so another thread with a pointer to it can still read or overwrite it. Step ③ below shows that.

Try it. The same two workers, A and B, first as threads in one process, then as two processes:

Roommates vs separate houses
Same two workers, A and B. Switch the model, then press the buttons in order.
PROCESS · PID 4120 · one address space
Codeshared
the server program
Heap + globalsshared
counter = 0
users = Map(1,204)
Open files & socketsshared
fd 3 → app.log
fd 4 → :8080 socket
Thread A · stackprivate
handle(req #1)
locals: req, user
registers · PC · SP
Thread B · stackprivate
handle(req #2)
locals: req, user
registers · PC · SP
Press ① then ②, in each model. Green tags = shared by every thread, blue = private to one thread.

Why share? Speed

  • Sharing is free. To give another thread a 100 MB array, you hand it the array's address. Nothing is copied. Between processes, those 100 MB must be copied through the OS (§09).
  • Creating is cheap. A new thread doesn't need a new address space, so it starts in microseconds. A new process takes much longer (§06).
  • Switching is a bit cheaper. Moving the CPU from one thread to another in the same process skips a step (§05).

The price of sharing

Two roommates can reach for the same egg. When two threads write the same variable at the same time, with nothing coordinating them, you get a Data race — and wrong results that change from run to run.

A worked example. counter is 5, and threads A and B each run counter++. The CPU does that in three steps: read, add 1, write back. If both threads read 5 before either writes, both compute 6 and both write 6. Two increments ran; the counter went up by one. One update is simply lost:

Two threads, one counter++
Threads A and B share counter (it starts at 5). Use ← → or Play.
Code
1// Both threads run counter++.
2// The CPU does it in three steps:
3let tmp = counter; // ① read into a register
4tmp = tmp + 1; // ② add
5counter = tmp; // ③ write back
Thread A · register
about to run counter++
Shared heapone copy
counter = 5
Thread B · register
about to run counter++
Two threads of the same process, A and B, each want to add 1 to the shared counter, which is 5. The right answer afterwards is 7. One line of code, counter++, is really three steps for the CPU.
1 / 8

The fix is a Lock (or passing data through a channel), so only one thread runs those three steps at a time. Race Conditions §02 digs into this bug; Async / Await, pitfall #5 and Channels §10 show both fixes.

When one thread crashes, the whole house burns

Suppose one thread has a bug and writes to a bad address. The CPU stops it and the OS steps in. The OS can't know what else that broken thread scribbled over in the shared memory, so nothing in the process can be trusted any more. It does the only safe thing: it kills the whole process, every thread with it.

The same is true of unhandled errors in most runtimes. An uncaught exception on Node's main thread, or a Go panic / recover that nobody recovers in any goroutine, ends the whole process.

Separate processes don't have this problem: a crash stays inside its own walls. The demo below has a second mode with a supervisor: just a parent process whose job is to start workers and restart any that die. (You'll meet real ones in §07, Node's cluster, and §11, Kubernetes.) Send a bad request to each setup:

Crash one thread vs crash one process
A server handles requests with 4 workers. Send a “bad request” that triggers a memory bug in one of them.
PROCESS · PID 4120 · 4 threads, shared memory
thread 1
req #1
thread 2
req #2
thread 3
req #3
thread 4
req #4
served: 0lost: 0
Press 💣 on any worker, in each model. Exit code 139 = 128 + 11, where 11 is the number of the “bad memory access” signal (SIGSEGV).
Foundations§04

The OS scheduler: who gets a core

In one sentenceA CPU core runs one thread at a time. The OS scheduler gives each ready thread a short turn — a few milliseconds — then swaps in the next, so thousands of threads appear to run at once.

Open your laptop's activity monitor: hundreds of processes and a few thousand threads, on maybe 8 cores. Each CPU core can only run one thread at any instant. How does that work?

They take turns. The part of the OS kernel that decides whose turn it is is the OS scheduler.

Turns, and taking turns away

Each running thread gets a Time slice — a few milliseconds. If it's still running when the slice is up, a Timer interrupt (a hardware alarm) forces the CPU back into the kernel (the CPU stops running your code and runs the OS's code instead), and the scheduler can hand the core to someone else. Pausing a thread that didn't ask to stop is called preemption.

Threads also give up the core voluntarily: whenever they have to wait — for the disk, the network, a lock, a timer — they're set aside until the wait is over. Step through both:

The OS scheduler, step by step
2 cores, 5 threads. Use ← → or Play.
Core 0 · running
T1
Core 1 · running
T2
Ready queuecan run
T3
T4
T5
Blockedwaiting on I/O
nobody
A 2-core machine with five threads (T1–T5) that all have work to do. Only two can run at any instant — one per core. The other three wait in the ready queue: they could run; they just don't have a core yet.
1 / 8

Three states

StateMeansOn a typical server, most threads are…
RunningOn a core right nowAt most one per core
Ready (runnable)Could run, waiting for a free coreFew, if the machine is healthy
Blocked (sleeping)Waiting for something else: I/O, a lock, a timerHere. Thousands of them, costing no CPU at all

That last column explains a lot. A blocked thread uses no CPU time, but it still holds memory: its stack, its kernel record. That's exactly why one thread per connection got expensive at 10,000 connections (Async / Await §03) — the threads weren't busy, they were just there.

Practitioner§05

Context switches: what a swap really costs

In one sentenceA Context switch saves one thread's place and loads another's. It takes a few microseconds — nothing once, but a real slice of your CPU at 100,000 times a second.

In §04 the scheduler "swapped" threads on a core. What does that actually involve? A thread's whole in-progress state lives in the core's registers: where it is in the code (the program counter), where its stack is (the stack pointer), and the values it's working on. Swap the registers and you've swapped the thread.

There are two kinds of switch, and you saw both in §04:

  • Voluntary — the thread blocked (waiting on disk, network, a lock).
  • Involuntary — its time slice ran out and it was preempted.

Step by step

Two words you'll meet in the stepper. Kernel mode: the CPU has a privileged mode for the OS and a restricted one for your code; crossing between them costs a little (User mode vs kernel mode). TLB: a small cache inside the CPU that remembers recent address translations from the page table (§02), so translating is usually instant (TLB).

Step through a switch from thread A to thread B. Then flip the toggle: is B in the same process as A, or a different one?

Inside a context switch
Core 0 switches from thread A to thread B. Compare both cases.
CPU core 0 · registers
running: thread A
PC = 0x4a10 next instruction
SP = 0x7ffe…a0 top of A's stack
rax = 42 data
A's saved statekernel memory
(A is running)
B's saved statekernel memory
PC = 0x51c4 next instruction
SP = 0x7ffd…38 top of B's stack
rax = 7 data
Address space in use
page table → P1
Core 0 is running thread A. The CPU's registers hold A's working state: PC (the program counter: which instruction is next), SP (the stack pointer: where A's stack is) and some data. Thread B is paused; its registers were saved earlier. A belongs to process P1, B to a different process, P2.
1 / 8

So the difference between the two cases is exactly one step: a switch to another process also switches the address space (the page table), and the new process finds none of its data in the CPU's caches.

What it costs, in numbers

All numbers are approximate — they vary a lot with the CPU, the kernel version and how much data each thread touches.

SwitchDirect costThen…
Goroutine → goroutine (Go runtime, no kernel)~0.1–0.3 µsSame thread, same address space — little cache damage
Thread → thread, same process~1–3 µsSome shared data may still be in cache
Thread → thread, other process~2–5 µsPage-table switch; the new process's data is cold

The "then…" column is often the bigger cost. After a switch, the new thread's data isn't in the CPU cache, so its first reads go to RAM — around 100 ns each instead of 1–10 ns.

A worked example. One core does 10,000 switches per second, and each costs about 5 µs including the cache refill. That's 10,000 × 5 µs = 50,000 µs = 50 ms of every second — 5% of the core — doing no useful work. At 100,000 switches per second, it's 50%. Try it:

How much CPU do switches eat?
Pick how often a core switches, and what it switches between. Costs are approximate and illustrative.
thread (same process): ~2 µs switch + ~3 µs of slow, cache-missing work afterwards = ~5 µs lost per switch.
One core, zoomed into 100 µsa switch every 100 µs
switchcache warm-upuseful work (one color per thread)
One whole second10,000 switches × 5 µs
≈ 5% of the core lost to switching (10,000 × 5 µs = 50 ms per second). Noticeable — you'd see it in a profile.

Why goroutines and event loops win here

This is the deeper reason behind Async / Await §03. With a thread per connection, every network wait means a trip into the kernel and a context switch. Go's runtime switches goroutines without entering the kernel at all — it just saves a few registers in user space, roughly ten times cheaper. And Node's event loop doesn't switch at all between callbacks: it's one thread that keeps running.

Measuring it (Linux)

Linux
# machine-wide context switches per second: the "cs" column
vmstat 1

# one process: voluntary = it blocked, nonvoluntary = it was preempted
grep ctxt_switches /proc/4120/status

# per thread, once a second (pidstat comes with the sysstat package)
pidstat -wt -p 4120 1
Practitioner§06

Starting processes: fork, exec and spawn

In one sentenceOn Unix, a new process is born in two steps: fork clones the current process, then exec replaces the clone's program with another one.

Why two steps instead of one "start this program" call? Because in between, the child is still running your code, so it can set itself up with ordinary function calls: connect its output to a pipe (a one-way byte channel, §09), change directory, drop privileges (switch to a less powerful user). Then it calls exec and becomes the new program, keeping that setup.

Step through what happens when a server runs ls -l. The code is pseudocode — neither Node nor Go lets you call these directly; they do it for you:

fork → exec → wait
Pseudocode for what Node's spawn() and Go's exec.Command do, conceptually.
Code
1const pid = fork(); // 1 in, 2 out
2if (pid === 0) {
3 // CHILD
4 exec("/bin/ls", ["ls", "-l"]);
5 // (never reached)
6} else {
7 // PARENT
8 const status = waitpid(pid);
9 console.log("exit:", status);
10}
Parent · PID 100
running your program
fd 1 → terminal
Child · PID 101
doesn't exist
Physical RAM pagesowner PIDs
code 100
heap 100
stack 100
Kernel · process table
100 · running
One process, PID 100. Its memory lives in physical RAM pages (4 KB chunks): code, heap and stack. Real programs have thousands of pages; three will do. fd 1 is its standard output, connected to the terminal.
1 / 7

Three ideas from that walkthrough:

  • Copy-on-write: fork doesn't copy memory up front. Parent and child share every page until one of them writes to it; that write causes a Page fault, and the kernel copies just that page. The kernel only copies the page table, not the gigabytes behind it, so forking even a big process is fast (milliseconds, not seconds).
  • Exit code: every process ends with a number. 0 = success; anything else = failure.
  • Zombie process: a finished child stays in the process table until its parent collects the exit code. Forget to collect it and the zombies pile up.

In real code

package main

import (
	"context"
	"fmt"
	"os/exec"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// No shell involved: "ls" is the program, "-l" and "/tmp" its arguments.
	cmd := exec.CommandContext(ctx, "ls", "-l", "/tmp")
	out, err := cmd.Output() // ← fork + exec + wait, collecting stdout
	if err != nil {
		fmt.Println("failed:", err) // non-zero exit code, timeout, or not found
		return
	}
	fmt.Print(string(out))
}

The child's standard streams — stdin / stdout / stderr — are how parent and child talk by default: Node and Go connect them to pipes, which is how the parent reads ls's output. More on pipes in §09.

Practitioner§07

Node: one main thread, and three ways out

In one sentenceYour JavaScript runs on one main thread. For CPU-heavy work, add threads (worker_threads). For other programs, crash isolation or using every core for a server, add processes (child_process, cluster).

"Node is single-threaded" is half true. Your JavaScript runs on one thread — the one running the Event loop. But a Node process has other threads too, and you can add more threads or more processes when you need them:

One Node process, from the inside — and its relatives outside
NODE PROCESS · PID 5001 · one address space
🧵 Main thread
Runs all your JavaScript and the event loop. One V8 heap with your objects.
⚙️ V8 helper threads
Garbage collection, compiling hot code. Not yours to use.
🏊 libuv thread pool · 4 threads by default
fs.readFile, dns.lookup, crypto.pbkdf2, zlib… — work the OS can't do asynchronously. Network sockets don't use it (they use epoll).
🧵 worker_threads Worker #1
Its own V8 isolate: own heap, own globals, own event loop. Talks to main via postMessage.
🧵 worker_threads Worker #2
Same process, so a SharedArrayBuffer can be truly shared — but plain objects can't.
⇅ pipes · IPC channel · sockets (the OS in between)
📦 child_process · PID 5002
Any program: git, ffmpeg, python, another node. Separate memory; talks through stdin/stdout pipes.
📦 cluster worker · PID 5003, 5004…
A full copy of your server in its own process, sharing the listening port. Nothing in memory is shared.

Threads you already have: the libuv pool

Some work the OS can't do in the background with a "wake me when it's done" call (the epoll / kqueue / IOCP trick from Async / Await §03): reading files, looking up hostnames with dns.lookup, slow crypto like pbkdf2 (password hashing), compression with zlib. For those, Node hands the job to the libuv thread pool and calls your callback when a pool thread finishes.

The pool has 4 threads by default. Run 4 slow password hashes at once and every fs.readFile queues behind them. You can raise it with the UV_THREADPOOL_SIZE environment variable (set it before Node starts; max 1024). Network sockets don't use the pool, so ordinary HTTP traffic isn't affected.

worker_threads: more JavaScript threads

A blocking calculation on the main thread freezes everything else (see Async / Await, pitfall #2). A Worker thread runs that code on another thread, so it can use another core:

import { Worker } from "node:worker_threads";
import { readFile } from "node:fs/promises";

// Run hash-worker.js on a new thread; resolve with whatever it sends back.
function hashInWorker(buf) {
  return new Promise((resolve, reject) => {
    const w = new Worker(new URL("./hash-worker.js", import.meta.url), {
      workerData: buf, // ← copied into the worker
    });
    w.once("message", resolve); // the worker's result
    w.once("error", reject);    // an uncaught throw inside the worker
    w.once("exit", (code) => reject(new Error(`worker exited (${code})`))); // never hang
  });
}

// files = the CONTENTS of each file (Buffers), not just their names
const files = await Promise.all(["a.txt", "b.txt", "c.txt", "d.txt"].map((f) => readFile(f)));

// 4 files → 4 threads → up to 4 cores busy at once.
// Fine for a handful; for 1,000 files use a pool (§12, pitfall #1).
const sums = await Promise.all(files.map(hashInWorker));

The key fact about workers: each one runs its own V8 isolate — a separate JavaScript world with its own heap and globals. It's an OS thread inside the same process, but JavaScript objects are not shared. Data moves in three ways:

  1. Copied (the default). postMessage copies the value with Structured clone. Fine for small messages; slow for 100 MB.
  2. Transferred. postMessage(buf, [buf]) moves an ArrayBuffer (a block of raw bytes; a Node Buffer sits on top of one) to the other thread with no copy. The sender can't use it any more.
  3. Shared. A SharedArrayBuffer + Atomics is real shared memory: both threads see the same bytes at once — with all the data-race risks from §03.

child_process: other programs

§06 showed execFile and spawn. Use them to run anything that isn't your JavaScript — git, ffmpeg, a Python script — or JavaScript you don't trust. It's a separate process: its own memory, its own crashes. Node's own child_process.fork() starts another Node process with a message channel attached (§09).

cluster: one server, every core

A single Node server uses one core for JavaScript. The cluster starts several copies of the server as separate processes, all accepting connections on the same port:

server.js
import cluster from "node:cluster";
import http from "node:http";
import { availableParallelism } from "node:os";

if (cluster.isPrimary) {
  // The primary process only manages workers.
  for (let i = 0; i < availableParallelism(); i++) cluster.fork(); // one per core
  cluster.on("exit", (worker, code) => {
    console.log(`worker ${worker.process.pid} died (code ${code}), starting another`);
    cluster.fork(); // ← the primary is a supervisor
  });
} else {
  // Each worker is a full copy of the server, in its own process.
  http.createServer((req, res) => res.end(`hi from ${process.pid}\n`)).listen(3000);
}

The primary is a Supervisor: when a worker crashes, it starts a new one — exactly the "4 processes + supervisor" mode of the §03 crash demo. But each worker has its own memory: an in-memory cache or session store exists once per worker, not once per server (§12, pitfall #5).

Which one?

You need to…UseShares JS memory?A crash takes down…
Read files, hash passwords, gzipNothing — the libuv pool does it——
Run CPU-heavy JavaScriptA pool of worker_threadsNo (only SharedArrayBuffer)That worker, for a JS error (if you handle 'error'; otherwise the whole process); the whole process for a native crash
Run another programchild_process (spawn, execFile)NoJust the child
Use every core for an HTTP servercluster, or N containersNoJust that worker
Practitioner§08

Go: goroutines on threads, all in one process

In one sentenceA Go program is one process. Inside it, the Go Runtime runs your goroutines on a few OS threads — you get threads' cheap sharing without paying for a thread per task.

A Goroutine is not an OS thread. The Go runtime keeps its own scheduler that puts goroutines onto threads, and the OS scheduler (§04) puts those threads onto cores. Running many tasks on a few threads like this is called M:N scheduling.

One Go process, three layers
GO PROCESS · PID 6001 · one shared heap
goroutines (could be 100,000) — scheduled by the Go runtime
G1G2G3G4G5G6G7G8G9G10G11G12G13G14… ×10,000
↓ run on ↓
OS threads (Go calls them M; ≈ GOMAXPROCS running Go code + a few extra) — scheduled by the OS
M0M1M2M3M4 · stuck in syscallM5 · idle
↓ run on ↓
core 0core 1core 2core 3
Thousands of goroutines share a handful of OS threads; the Go runtime decides which goroutine runs on which thread (the G·M·P scheduler: G = goroutine, M = OS thread, P = a slot for running Go code). The OS scheduler (§04) then decides which thread runs on which core. A thread stuck in a blocking system call is replaced, so the cores stay busy.

How the Go scheduler decides which goroutine runs on which thread — the G · M · P model, the netpoller, work stealing — is covered step by step in Async / Await §09. Here we care about what it means for threads and processes.

Goroutines vs threads, in numbers

All approximate:

OS threadGoroutine
StackUsually 1–8 MB reserved (fixed size)Starts at ~2 KB, grows as needed
Create~10 µs or more (a system call)Well under 1 µs (no system call)
Switch~1–5 µs, through the kernel (§05)~0.1–0.3 µs, inside the runtime
Practical countThousandsHundreds of thousands

The number of threads running Go code at once is GOMAXPROCS, one per core by default. The runtime starts a few extra threads when goroutines get stuck in blocking system calls, so a busy Go server typically shows a dozen or so threads.

What "one process" means for you

  • Every goroutine shares one heap. Goroutines are roommates: two of them writing the same map is a Data race (§03). Go often catches this one itself and kills the process (see the third bullet). Use a mutex or channels (Channels §10), and run tests with go test -race.
  • One unrecovered panic ends everything. A panic / recover in any goroutine, if not recovered inside that same goroutine, exits the whole program. Go's HTTP server recovers panics in your handlers — but not in goroutines your handlers start (§12, pitfall #4).
  • Some crashes can't be recovered at all. "Fatal errors" — concurrent map writes, running out of memory, a segfault in C code called from Go — kill the process no matter how many recover() calls you have.

Threads, when you need to see them

rarely needed, good to know
// Pin this goroutine to its current OS thread (and no other goroutine
// runs on that thread). Needed for C libraries or GUI toolkits that
// insist on being called from the same thread every time.
runtime.LockOSThread()
defer runtime.UnlockOSThread()

// Go crashes the program if it ever needs more than this many OS threads
// (default 10,000) — usually a sign of thousands of goroutines stuck in
// blocking system calls or C calls.
debug.SetMaxThreads(10_000) // from "runtime/debug"

Processes in Go

Go has no plain fork (§06 explains why). To start another process, use os/exec, as in §06. Real Go systems do use processes for isolation: HashiCorp's go-plugin library — used by Terraform (a tool that creates cloud servers from config files) for its providers, the plugins that talk to each cloud — runs each plugin as a separate process and talks to it over gRPC on a local socket. A buggy provider can crash itself, not Terraform.

Practitioner§09

Talking: shared memory vs IPC

In one sentenceThreads talk by reading and writing the same memory. Processes talk by sending bytes through the OS — slower, because the data is copied, but nobody can reach into anyone else's memory.

Threads: share an address

Between threads (and goroutines), you just use the same variable, or pass a pointer — an address in the shared heap. Handing over a 10 MB object costs the same as handing over a number: nanoseconds.

The catch is coordination. Two threads touching the same data need a Lock, or a rule that only one of them owns it at a time — which is what passing it through a Go channel achieves (Channels §01).

Processes: send bytes through the OS

Processes can't share an address (§02), so every option for IPC goes through the kernel:

MechanismDirectionTypical use
PipeOne wayParent ↔ child via stdin/stdout; the shell's ls | grep x
Unix domain socketTwo way, same machineTalking to a local Daemon: Docker (/var/run/docker.sock), Postgres, a Sidecar
TCP SocketTwo way, any machineHTTP, gRPC, databases — the same API across the network
Shared memoryBoth read and writeVery large data, very low latency; Postgres's buffer cache, Chrome's video frames
SignalA tiny notification"Please stop" (SIGTERM), "reload config" (SIGHUP); more in §11
FilesAnythingHand-offs that must survive a restart

What copying really costs

A pipe carries bytes, not objects. To send an object you must turn it into bytes (Serialization, e.g. JSON.stringify), the kernel copies those bytes in and out, and the other side parses them back:

Handing a 10 MB object to someone else
threads
processes
JSON.stringify
copy → kernel
kernel → B
JSON.parse
Between threads you pass a pointer (an address) — nanoseconds, no matter the size. Between processes the bytes must be serialized, copied into the kernel, copied out to the other process, and parsed — every step grows with the size. Bars are illustrative, not to scale.

Concrete numbers (approximate): a 10 MB JSON payload takes tens of milliseconds to stringify and about as long again to parse. Send it 50 times per second and those two steps alone can fill a core. Between threads, the same hand-off is a pointer.

A pipe in practice

Run the Unix sort program as a child, write lines into its stdin, read the sorted result from its stdout:

cmd := exec.Command("sort")                         // the child process
cmd.Stdin = strings.NewReader("pear\napple\nfig\n") // ← fed into its stdin pipe
out, err := cmd.Output()                            // ← read from its stdout pipe
if err != nil {
	log.Fatal(err)
}
fmt.Print(string(out)) // apple, fig, pear — one per line

Node-to-Node: a message channel

Node's child_process.fork() starts another Node process and adds a message channel on top of a pipe. Messages are serialized (JSON by default) on the way:

import { fork } from "node:child_process";

const child = fork("./child.js"); // a new Node process (path relative to the current directory)
child.on("message", (msg) => {
  console.log("child says", msg); // { done: 7 }
  child.disconnect();             // close the channel so both can exit
});
child.send({ task: "resize", id: 7 }); // ← serialized, sent through the pipe
Staff-level§10

When each wins: Chrome, Postgres, MySQL, Nginx, Redis

In one sentenceProcesses buy you walls: crash safety, security, separate limits. Threads buy you cheap sharing: less memory, faster hand-offs. Real systems pick per component — and usually mix both.

The decision table

If you need…Lean towardsBecause
One bad request mustn't take down the restProcessesA crash stays inside its walls (§03)
To run code you don't trust (sites, plugins, user scripts)ProcessesIsolation is enforced by hardware (§02)
To share large data or update shared state constantlyThreadsA pointer instead of a copy (§09)
Thousands of concurrent connectionsThreads, goroutines or an event loopA process per connection costs MBs each (§04, and Postgres below)
Separate memory/CPU limits, independent restartsProcessesThe OS limits and kills per process (§11)
To run a different program or languageProcessesThe only option

Now five systems you've probably used, and the side each one picked.

Chrome: a process per site

Chrome runs a browser process (tabs, address bar), a GPU process, a network process, and renderer processes that run each site's HTML and JavaScript. Since Chrome 67 (2018), desktop Chrome gives each site its own renderer process ("Site Isolation").

  • Crashes: a tab that crashes shows "Aw, Snap!" — the rest of the browser keeps running.
  • Security: web pages are untrusted code. After Spectre showed that JavaScript could read memory within its own process through CPU timing tricks, the only safe answer was to never put two sites in the same process. The address-space walls from §02 became the security boundary.
  • The price: memory. Each renderer has its own heap, its own copy of engine data. That's a big part of why Chrome uses so much RAM.

Postgres: a process per connection

Postgres has a main process (the postmaster) that forks a new backend process for every client connection. Isolation is good, and the design dates from when threads were poorly supported across operating systems.

  • The price: each backend costs several MB at least (approximate; more with big queries). Thousands of connections means thousands of processes, so busy apps put a Connection pooler like PgBouncer in front.
  • The twist: the backends share a large shared memory region (the buffer cache). If one backend dies abnormally — a memory fault, or the OOM killer (§11) — the postmaster can't trust that memory any more — so it terminates every backend and recovers. Every connection drops. Sharing memory weakened the walls (§09).

MySQL: a thread per connection

MySQL runs one process (mysqld) with a thread per connection (some editions add a thread pool). Connections are cheaper than Postgres's, and all threads share caches directly. The flip side is the §03 rule: a crash in any thread takes the whole server down.

Nginx: a few processes, each with an event loop

Nginx runs a master process and a few worker processes — usually one per core — and each worker handles thousands of connections with an event loop. Processes give multi-core use and isolation; the event loop gives scale without a thread per connection. That's the same shape as Node + cluster (§07).

Redis: one main thread, and fork() for snapshots

Redis executes commands on a single main thread, so commands never race with each other (Redis 6+ can add I/O threads for network reading and writing). To save a snapshot to disk without pausing, it calls fork: the child writes the data to disk while the parent keeps serving.

That works because of Copy-on-write (§06): the child sees a frozen copy of memory for free. Only pages the parent changes during the save get duplicated.

Staff-level§11

Production: limits, OOM kills and containers

In one sentenceA container is just a process with extra walls (what it can see) and a budget (what it can use). Most production surprises are a process hitting one of its limits.

A container is a process

There's no "container" object in the Linux kernel. A Container is an ordinary process — your node server.js or Go binary — started with two extra kernel features:

  • Namespaces control what it can see: its own list of PIDs (your app is often PID 1 inside), its own network interfaces, its own file system.
  • cgroups control how much it can use: CPU time, memory, number of processes.
docker
# 512 MB of RAM, 2 CPUs' worth of time, at most 200 processes + threads
docker run --memory=512m --cpus=2 --pids-limit=200 my-api

The limits you'll hit

LimitSet byWhat hitting it looks like
Open files and socketsulimit -n (often 1,024 by default)EMFILE: too many open files — every connection is an File descriptor
Processes and threadsulimit -u, cgroup pids.maxEAGAIN, "can't create thread"; Go: failed to create new OS thread
Memorycgroup memory limitThe process is killed: exit code 137, Kubernetes says OOMKilled
CPUcgroup CPU limitNothing crashes — the process is throttled (paused) for part of every 100 ms, and latency jumps

On Linux, threads count against the process limits — a thread is a "task" just like a process (§04).

When memory runs out, the kernel's OOM killer picks a victim and sends it SIGKILL. A signal is the OS's way of tapping a process on the shoulder; SIGKILL (signal 9) is the one that can't be caught or ignored: the process just dies (128 + 9 = exit code 137). It kills a whole process, never just one thread — the roommates go down together again. That's why you tell the runtime the budget, so it can clean up before the kernel steps in:

  • Node: --max-old-space-size=384 (in MB) caps the JavaScript heap. Set it well below the container limit; buffers and native memory live outside it.
  • Go: GOMEMLIMIT=450MiB (Go 1.19+) makes the garbage collector work harder as memory approaches the limit.
  • Go CPU: Go 1.25+ sets GOMAXPROCS from the container's CPU limit, when go.mod says go 1.25 or later (§08). Node's libuv pool size (§07) doesn't adapt; set it yourself if you change it.

Shutting down politely: SIGTERM and PID 1

When Kubernetes (or docker stop) stops a container, it sends SIGTERM — "please finish up" — waits a grace period (30 s by default in Kubernetes), then sends SIGKILL, which can't be caught. Unlike SIGKILL, SIGTERM can be caught. Catch it, stop accepting new requests, finish the in-flight ones, exit:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()

srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}()

<-ctx.Done() // ← blocks until SIGTERM (or Ctrl-C) arrives
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// stop accepting; wait for in-flight requests (max 20 s)
if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("forced shutdown: %v", err) // the 20 s ran out
}

Here's what the handler buys you, and what happens without one:

Stopping a container: SIGTERM, then SIGKILL
Kubernetes sends SIGTERM at 5 s, then SIGKILL if the process is still alive 30 s later. Time is stretched so you can watch it.
process
request 1
request 2
request 3
request 4
new req 1
new req 2
0 s10 s20 s30 s40 s
SIGTERMfinishedcut off / refused
Four requests are in flight. Watch what happens when SIGTERM arrives, then switch modes.

Why the PID 1 remark? PID 1 is special: the kernel doesn't apply the default "die" action to signals it hasn't installed a handler for, and it adopts every Orphan process (a child whose parent died), so it's expected to Reap them when they exit — otherwise they stay zombies (§06). Two practical rules:

  1. Start your app directly (CMD ["node", "server.js"]), not via npm start or a shell script that may not forward SIGTERM.
  2. If your app starts child processes, run a tiny init in front of it — docker run --init or tini — to forward signals and reap zombies.

Seeing threads and processes

Linux
ps -o pid,nlwp,rss,comm -p 4120   # threads (NLWP) and memory (RSS) of one process
top -H -p 4120                    # live CPU use per THREAD of that process
ls /proc/4120/task | wc -l        # thread count, straight from the kernel
cat /sys/fs/cgroup/memory.max     # inside a container (cgroup v2): your memory limit
Practitioner§12

Pitfalls gallery

Six mistakes I've seen in real code reviews, each with the fix.

#1 · JS

A new Worker for every request

Each Worker boots a full JavaScript engine (§07): tens of milliseconds and several MB before it does any work. Under load, you spend more time starting workers than using them — and with no limit, memory grows with traffic. Keep a small pool alive and reuse it.

✕ Don't
app.post("/thumb", (req, res) => {
  // a whole new JS engine per request
  const w = new Worker(new URL("./thumb.js", import.meta.url), {
    workerData: req.body,
  });
  w.once("message", (img) => res.send(img));
});
✓ Do
import Piscina from "piscina"; // a pool of reusable worker threads
// thumb.js: export default (body) => makeThumbnail(body);
const pool = new Piscina({
  filename: new URL("./thumb.js", import.meta.url).href,
});

app.post("/thumb", async (req, res) => {
  res.send(await pool.run(req.body)); // waits in a queue if all are busy
});
#2 · Both

Building a shell command from user input

Node's exec runs its string through a shell (/bin/sh), so anything in name that the shell understands — ;, &&, $(…) — runs too. execFile and spawn start the program directly and pass each argument as-is. Go's exec.Command never uses a shell, unless you write exec.Command("sh", "-c", …) yourself — which brings back the same bug.

✕ Don't
import { exec } from "node:child_process";
// name = "cat.png; rm -rf ~"  ← runs BOTH commands
exec(`convert ${name} thumb.png`);
✓ Do
import { execFile } from "node:child_process";
// No shell: name is ONE argument, whatever it contains.
execFile("convert", [name, "thumb.png"], (err) => {
  if (err) console.error(err);
});
#3 · Go

Start() without Wait()

cmd.Start() launches the child and returns immediately. If nobody calls cmd.Wait(), the finished child stays a zombie (§06), and any pipes you attached (e.g. cmd.StdoutPipe(), which hands you the child's output as a stream) stay open. One per request, and the server eventually runs out of PIDs or file descriptors.

✕ Don't
cmd := exec.Command("convert", in, out)
cmd.Start() // runs in the background…
return nil  // …and nobody ever waits for it
✓ Do
cmd := exec.CommandContext(ctx, "convert", in, out)
if err := cmd.Start(); err != nil {
	return err
}
// …other work while it runs…
return cmd.Wait() // collects the exit code, closes pipes
#4 · Go

A panic in a goroutine you started

Go's HTTP server recovers panics inside your handler. But a goroutine the handler starts is on its own: if it panics, the whole process exits (§08), taking every other request with it. Recover inside the goroutine — and copy what you need from the request, since the handler may have returned by then.

✕ Don't
func handler(w http.ResponseWriter, r *http.Request) {
	go sendAnalytics(r.URL.Path) // a panic here kills the server
	w.Write([]byte("ok"))
}
✓ Do
func handler(w http.ResponseWriter, r *http.Request) {
	path := r.URL.Path
	go func() {
		defer func() {
			if v := recover(); v != nil {
				log.Printf("analytics panicked: %v", v) // the server lives on
			}
		}()
		sendAnalytics(path)
	}()
	w.Write([]byte("ok"))
}
#5 · JS

In-memory state with several processes

With cluster, PM2 or several containers, each process has its own copy of every variable (§07). A user logs in on worker 1 and is "logged out" on worker 2. Keep shared state outside the processes, for example in Redis.

✕ Don't
const sessions = new Map(); // exists once PER PROCESS
app.post("/login", (req, res) => {
  sessions.set(token, user);
  // …
});
app.get("/me", (req, res) => res.json(sessions.get(token))); // often undefined
✓ Do
// shared by every process (node-redis client)
await redis.set(`session:${token}`, JSON.stringify(user), { EX: 3600 });

const raw = await redis.get(`session:${token}`);
const user = raw && JSON.parse(raw);
#6 · Both

More busy threads than cores

For CPU-heavy work, threads beyond the number of cores don't add speed; they add context switches (§05). Classic versions: a worker pool of 32 in a container limited to 2 CPUs; a Go service on a version before 1.25 (or whose go.mod says an older go line) using GOMAXPROCS = 64 host cores inside that same container (§08); UV_THREADPOOL_SIZE=128 "to be safe". Size CPU pools to the CPU limit, not the host. (For I/O waits, more threads are fine — they spend their time blocked, §04.)

Practitioner§13

Check yourself: predict the outcome

Commit to an answer before you click. Each one maps to a section: crashes (§03), memory walls (§02, §07), fork (§06), switching (§05) and per-process state (§07).

QUESTION 1 / 6
Go: a panic in another goroutine
func main() {
    go func() {
        panic("boom")
    }()
    time.Sleep(time.Second)
    fmt.Println("main survived")
}
QUESTION 2 / 6
Node: does a worker see main's globals?
// main.js
import { Worker } from "node:worker_threads";
globalThis.count = 41;
new Worker(new URL("./w.js", import.meta.url));

// w.js
console.log(globalThis.count);
QUESTION 3 / 6
Node: an uncaught error inside a worker
// bad.js:  throw new Error("boom");
import { Worker } from "node:worker_threads";
const w = new Worker(new URL("./bad.js", import.meta.url));
w.on("error", (e) => console.log("worker died:", e.message));
setTimeout(() => console.log("main alive"), 100);
QUESTION 4 / 6
After fork(), the child writes
// pseudocode, as in §06 (fork/waitpid aren't real JS functions)
let x = 1;
const pid = fork();
if (pid === 0) { x = 99; exit(0); }   // child
waitpid(pid);
console.log(x);                        // parent prints…
QUESTION 5 / 6
Context-switch arithmetic
// One core does 20,000 context switches per second.
// Each costs ~5 µs (switch + cache warm-up).
// How much of that core goes to switching?
QUESTION 6 / 6
cluster: an in-memory counter
// Started with cluster: 4 worker processes, same code.
let hits = 0;
http.createServer((req, res) => {
  hits++;
  res.end(String(hits));
}).listen(3000);
// 8 requests arrive, spread evenly. The 8th response says…
Answered 0 / 6
Foundations§14

Cheat sheet

🏠
Process = a houseIts own memory (address space), PID, open files. Walls enforced by the hardware's address translation.
🧑‍🤝‍🧑
Thread = a roommateShares heap, globals, code and files with its process. Keeps only its own stack and registers.
🔥
Crashes respect walls, not roomsA segfault or unrecovered panic kills the whole process. Another process is unaffected.
🪑
The OS schedules threadsRunning → ready → blocked. A few ms per turn; blocked threads cost memory, not CPU.
🔁
Switches cost µs + cold caches~1–5 µs each, more across processes. 10,000/s × 5 µs = 5% of a core.
🧬
fork → exec → waitClone (copy-on-write), replace the program, collect the exit code — or leave a zombie.
📮
Share by pointer vs by copyThreads pass addresses (needs locks). Processes serialize and copy through pipes and sockets.
📦
Containers are processesNamespaces (what it sees) + cgroups (what it uses). Exit 137 = OOM-killed. Handle SIGTERM.
If you need…JavaScript (Node)Go
CPU-heavy work on all coresA pool of worker_threads (Piscina)More goroutines
Run another programexecFile / spawn from node:child_processexec.CommandContext + Output() or Start()/Wait()
Talk to a childIts stdin/stdout pipes; fork() + send() for Node childrencmd.Stdin + cmd.Output()
Use every core for a servercluster, or one container per coreNothing — one process uses all cores
Share memory between threadsSharedArrayBuffer + AtomicsAny variable + a mutex or a channel
Survive a crash in risky codeRun it in a child processRun it in a child process (e.g. go-plugin)
Stop politelyprocess.on("SIGTERM", …) + server.close()signal.NotifyContext + srv.Shutdown()
Respect the container budget--max-old-space-sizeGOMEMLIMIT; GOMAXPROCS is automatic in Go 1.25+ (with go 1.25 in go.mod)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Concurrency & AsyncAsync / Await
Read →