Skip to main content

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

TermMeaning
ProcessAn instance of a running program: address space, open files, signal handlers, and one or more threads, isolated from other processes.
ThreadAn 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 switchThe 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 threadA 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:

Process state diagram: Created leads to Waiting, which exchanges with Running, which leads to Blocked and to Terminated, with swapped-out variants of Waiting and Blocked living in swap space
The state machine. The lower band matters: a swapped-out process is not just idle, it is not even resident, and waking it needs disk I/O before it can run. Wikimedia Commons, Public domain
Naming varies by system

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., CR3 on 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

ModelWho schedules itBlocking syscall behaviorExample
1:1 (kernel threads)The OS scheduler, directlyOne thread blocking (e.g., on I/O) doesn't stall its siblingsPOSIX threads (pthreads) on Linux, Windows threads
N:1 / M:N (green threads)A userspace runtime, multiplexed onto one or few kernel threadsA blocking syscall can stall the whole runtime unless it's wrapped in a non-blocking/async I/O layerEarly 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:

A queue of pending tasks feeding a fixed row of worker threads, one of which is idle, with finished tasks accumulating in a completed queue on the other side
Threads are created once and reused. Work queues up in front of the pool rather than spawning a thread each, so cost scales with the work, not with the thread count. Wikimedia Commons, CC BY-SA 3.0

Sizing the pool is the part people get wrong, and the right answer depends on what the work does:

WorkloadSensible pool sizeWhy
CPU-bound≈ number of physical coresMore threads than cores just adds context switches; there is no idle time to fill.
I/O-boundConsiderably more than coresThreads spend most of their life blocked, so a core can usefully carry many of them.
MixedSeparate poolsOne pool per workload class; a slow I/O task must not occupy a slot meant for CPU work.
Unbounded queues turn backpressure into memory exhaustion

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

fork() only duplicates the calling thread

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.

Thread creation is not "free," and address-space sharing cuts both ways

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

ProcessThread
Address spacePrivate, isolatedShared with sibling threads
Creation costHigh (fork() + page-table setup)Low (new stack + PCB entry)
CommunicationRequires explicit IPCDirect memory access (needs synchronization)
Fault isolationA crash doesn't affect other processesA crash can corrupt the whole process
Context-switch costHigher (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.