Processes & Threads
Overview
A process is the OS's unit of isolation: a running program with its own private address space, open file descriptors, and execution state, walled off from every other process by the kernel. A thread is the OS's unit of scheduling: an independent sequence of execution that can run concurrently with other threads. The key distinction between the two is what they share — every thread within a process shares that process's address space, file descriptors, and most other resources, while each process gets its own private copy of everything. That single fact (shared vs. private address space) explains almost every practical difference between "spawn a thread" and "spawn a process."
Core Concepts
| Term | Meaning |
|---|---|
| Process | An instance of a running program: address space, open files, signal handlers, and one or more threads, isolated from other processes. |
| Thread | An independent, schedulable execution context (its own stack, registers, program counter) that shares its parent process's address space with sibling threads. |
| Process Control Block (PCB) | The kernel data structure (task_struct on Linux) holding everything the OS needs to know about a process/thread: PID, state, saved registers, memory maps, open file table, scheduling priority. |
| Context switch | The kernel saving the CPU state of the currently running thread into its PCB and restoring another thread's saved state, so execution can resume later exactly where it left off. |
| Kernel thread (1:1 model) | A thread the kernel scheduler knows about directly and can schedule onto a CPU core independently of its siblings. |
| Green thread / user-level thread | A thread multiplexed onto one or more kernel threads entirely by a userspace runtime; the kernel is unaware of it individually. |
Architecture / Mechanism
A process is not simply "running" or "not running" — the scheduler moves it between several states, and knowing which one a stuck process is in is most of diagnosing it:

This diagram's Waiting is what Linux calls runnable (R in ps) — ready to run, waiting only
for a CPU. Its Blocked is Linux's sleeping (S/D), waiting on I/O or a lock and not
schedulable. The distinction is the one that matters when reading load average: runnable processes
count toward it, and on Linux so do processes in uninterruptible sleep (D), which is why heavy disk
I/O can drive load average up with the CPUs nearly idle.
Threads within a process are cheap to create and communicate through because they read and write the same memory directly. Processes are expensive to create and communicate through precisely because they don't share memory — any communication has to go through the kernel (see Inter-Process Communication).
What a context switch actually saves and restores
When the kernel switches from thread A to thread B on a CPU core, it must save into A's PCB (and load from B's PCB) at least:
- CPU registers — general-purpose registers, the program counter, the stack pointer.
- Processor status/flags register.
- Memory-management state — if switching between processes (not threads of the same process),
the page-table base register (e.g.,
CR3on x86-64) must also change, which invalidates address-translation caching in the TLB (see Memory Hierarchy & RAM) and causes a burst of expensive TLB misses right after the switch. - Kernel bookkeeping — scheduling info, signal masks, and enough state to resume the syscall the thread was in, if any.
This is why context switches aren't free: beyond the direct cost of saving/restoring registers, a process-to-process switch cools down the CPU's caches and TLB for the new process, and those misses have to be paid back on the following instructions. Switching between two threads of the same process is cheaper precisely because the page tables (and therefore the TLB) don't need to change.
Practical Usage
Thread models: 1:1 kernel threads vs. green threads
| Model | Who schedules it | Blocking syscall behavior | Example |
|---|---|---|---|
| 1:1 (kernel threads) | The OS scheduler, directly | One thread blocking (e.g., on I/O) doesn't stall its siblings | POSIX threads (pthreads) on Linux, Windows threads |
| N:1 / M:N (green threads) | A userspace runtime, multiplexed onto one or few kernel threads | A blocking syscall can stall the whole runtime unless it's wrapped in a non-blocking/async I/O layer | Early Java "green threads", Go's goroutines (M:N onto OS threads), Erlang processes |
Thread pools: stop creating threads per unit of work
Creating a thread is far cheaper than creating a process, but it is not free — it means a syscall, a fresh kernel stack, and a new entry in the scheduler's run queue, typically tens of microseconds. For work items measured in microseconds themselves, creation dominates. The standard answer is to create a fixed set of threads once and feed them work:

Sizing the pool is the part people get wrong, and the right answer depends on what the work does:
| Workload | Sensible pool size | Why |
|---|---|---|
| CPU-bound | ≈ number of physical cores | More threads than cores just adds context switches; there is no idle time to fill. |
| I/O-bound | Considerably more than cores | Threads spend most of their life blocked, so a core can usefully carry many of them. |
| Mixed | Separate pools | One pool per workload class; a slow I/O task must not occupy a slot meant for CPU work. |
A pool with a fixed thread count and an unbounded task queue does not reject work when overloaded —
it accepts it and grows the queue until the process runs out of memory. Bound the queue and decide
explicitly what happens when it is full (block the submitter, drop, or fail fast). Java's
Executors.newFixedThreadPool uses an unbounded queue by default, which is exactly this trap.
fork/exec (POSIX) vs. CreateProcess (Windows)
POSIX splits "make a new process" and "run a different program in it" into two distinct syscalls:
#include <unistd.h>
#include <sys/wait.h>
pid_t pid = fork(); // clones the calling process; returns twice
if (pid == 0) {
// Child: an almost-exact copy of the parent's address space (copy-on-write)
execvp("/bin/ls", (char *[]){"ls", "-l", NULL}); // replaces the image in place
_exit(127); // only reached if exec fails
} else {
// Parent: pid is the child's PID
waitpid(pid, NULL, 0); // block until the child exits
}
fork() gives the child a copy-on-write duplicate of the parent's entire address space; execvp()
then discards that address space and replaces it with a freshly loaded program image. Splitting the
two steps is what makes it trivial to set up file descriptors, environment variables, or working
directories in the child between fork() and exec() (which is exactly how shells implement I/O
redirection and pipelines).
Windows instead exposes a single CreateProcess() call that both creates the new process and loads
the target executable's image into it in one step — there is no separate "clone myself" primitive.
That design is simpler for the common case ("just start this program") but makes fork-style
copy-then-customize tricks awkward, which is part of why POSIX compatibility layers on Windows
(Cygwin, WSL1) historically struggled to emulate fork() efficiently.
Edge Cases & Pitfalls
In a multithreaded process, fork() clones the entire address space but only the calling thread
continues to exist in the child — other threads simply vanish without running their cleanup code.
Mutexes held by now-nonexistent threads can be left permanently locked. POSIX explicitly documents
this; the safe pattern is to fork() before spawning additional threads, or to immediately exec()
in the child.
Because threads share memory, a bug in one thread (a wild pointer write, a buffer overflow) can silently corrupt another thread's data with no kernel protection between them — unlike a crash in one process, which the kernel isolates from every other process automatically.
- Zombie processes: a child that has exited but whose parent hasn't called
wait()/waitpid()yet still occupies a PCB entry until it's reaped. - Orphaned processes (parent exits first) are re-parented (to
init/PID 1 on Unix) so they can still be reaped.
Comparisons
| Process | Thread | |
|---|---|---|
| Address space | Private, isolated | Shared with sibling threads |
| Creation cost | High (fork() + page-table setup) | Low (new stack + PCB entry) |
| Communication | Requires explicit IPC | Direct memory access (needs synchronization) |
| Fault isolation | A crash doesn't affect other processes | A crash can corrupt the whole process |
| Context-switch cost | Higher (page tables, TLB flush) | Lower (registers only, same address space) |
References
- Michael Kerrisk, The Linux Programming Interface — Chapters 24–28 cover processes,
fork(),exec(), and process termination in detail. - Remzi H. Arpaci-Dusseau & Andrea C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces — "The Abstraction: The Process" and "Interlude: Process API" chapters.
fork(2),execve(2),wait(2)— Linux man-pages (man7.org).
Books & Videos
- Remzi H. Arpaci-Dusseau & Andrea C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces — free online at ostep.org.
- Michael Kerrisk, The Linux Programming Interface (No Starch Press) — the definitive reference for the Linux/POSIX process and thread APIs used in the examples above.
- Andrew S. Tanenbaum & Herbert Bos, Modern Operating Systems — process/thread models chapter.
- MIT 6.1810 (formerly 6.S081/6.828), Operating System Engineering — free course building a Unix-like kernel (xv6): pdos.csail.mit.edu/6.1810.
Related Pages
- Scheduling — how the kernel decides which thread runs next.
- Concurrency & Synchronization — the price paid for sharing memory between threads.
- Inter-Process Communication — how isolated processes talk anyway.
- Memory Hierarchy & RAM — the TLB effects of a process context switch.