The idea: what a thread shares, and a process does not

A process is a running program with its own address space: its own page table, which the CPU loads into the CR3 register when the process runs. A thread is one line of execution inside a process. All threads of a process share its address space, its open files and its signal handlers; each thread has only its own stack and its own registers. On Linux both are "tasks" to the scheduler; the difference is how much they share.

Left: two processes, each with its own page table and its own code, data, heap and stack. Right: one process with two threads that share one page table, code, data and heap, and have one stack each.
Two processes have two page tables and two copies of every variable; two threads share one page table and differ only in their stacks and registers.

The page runs the same small program both ways. The left lane is two processes, P1 and its child P2 made with fork(). The right lane is one process with two threads, T1 and T2 made with pthread_create(). Each address space has four pages (code, data with a global int counter, heap with a 4 KB buffer buf, stack), and the physical frames below show how many page-table entries point at each one.

Processes: fork() and copy-on-write

fork() makes a new task and a new page table. It copies every page-table entry (PTE), not the memory itself: both processes point at the same frames, and the kernel marks the writable ones read-only. This is copy-on-write (COW). The first write to such a page faults; the kernel copies the page for the writer and lets it continue. A page that has only one user left is just made writable again. (Demo: create worker 2; see Copy-on-Write fork() for the details.)

From then on the two processes never see each other's writes. To talk they go through the kernel: a pipe, a socket, a signal, or a shared-memory segment that both map on purpose (mmap(MAP_SHARED), shm_open).

How the shell sets up such a pipe between two processes, and how the 64 KiB buffer blocks each side, is on How the Shell Runs ls | grep foo.

Threads: clone() that shares everything

pthread_create() calls clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | …). Each flag means "share this instead of copying it". The new task gets the same page table, so nothing is copied and nothing faults; glibc only maps a new stack (8 MB of address space by default, with a guard page). Both threads have the same process ID (the thread group ID) and different thread IDs.

What each costs

ProcessesThreads
CreateCopy the page table (one PTE per mapped page), then a COW fault per page writtenA task and a stack; nothing copied
MemoryShared until written, then one copy per processOne copy, plus one stack per thread
Context switchLoads a new CR3; TLB entries of the old process are useless (PCID helps)Same CR3 between threads of one process; the TLB stays warm
CommunicationThrough the kernel: write + read copy the data twicePass a pointer: 0 bytes copied
Shared dataNothing shared unless mapped on purposeEverything shared: needs locks or atomics
A crashKills that process; the parent gets SIGCHLDKills every thread of the process
Pick it forIsolation: browsers (one process per site), PostgreSQL backends, nginx workers, fork + execCheap sharing: a JVM, a Go program, a game engine, a thread pool

In Demo: send 4 KB, the pipe copies the result from P2's heap into the kernel's pipe buffer and then into P1's heap: 8192 bytes for a 4 KB result, and two system calls. The thread puts an 8-byte pointer on a queue and wakes T1 with a futex; T1 reads the result where it is.

Sharing means racing

In Demo: shared counter, each worker adds 1 twice. T1 prints 4: the threads change one variable. P1 prints 2: after the first write, P2's counter lives in a different frame. Sharing is what makes threads fast, and it is also what makes them dangerous. counter++ is three instructions: load, add, store. Two threads on two cores can both load the same value, and one update is lost (Demo: counter++ without a lock).

Time runs down. T1 loads counter 0, T2 loads counter 0, T1 stores 1, T2 stores 1. The counter is 1 after two increments: one update is lost. With a mutex or an atomic add, T2 would load 1 and store 2.
Without a lock, both threads read 0 and both write 1: two increments, one result. A mutex or an atomic add (lock xadd) makes the three steps one.

A pthread_mutex_t around the increment, or atomic_fetch_add, fixes it. The processes cannot race on their counters, because they do not share them; once they map shared memory, they need the same locks. See Concurrency vs Parallelism (a counter++ race on one core) and Mutex with a futex.

Crashes and isolation

In Demo: worker 2 crashes, worker 2 writes through a NULL pointer. The MMU finds no mapping for address 0, the kernel finds no memory area there, and it sends SIGSEGV. Its default action terminates the process. On the left that is only P2: its page table is torn down, P1 gets SIGCHLD, and waitpid() reports "killed by signal 11". P1 can start a new worker. On the right the process is both threads, so T1 dies too, in the middle of its work. This is why Chrome runs each site in its own process and why a PostgreSQL backend crash does not take the server's other connections with it (the postmaster restarts them all only as a precaution).

What to try

  • Create Worker 2: the threads lane is done after 2 steps; fork() needs 3 and copies 4 PTEs and a page.
  • counter++ ×2 twice: the second time no page faults, since every page has been copied already.
  • Send 4 KB before counter++: P2's write to buf is the first, so its heap page is copied then.

What the page leaves out

Each region is one page, and the frames are a short list; a real process has thousands of pages and a four-level page table (see Virtual memory and Context switch). The page leaves out the scheduler (the workers take turns in a fixed order), file-descriptor and signal-handler tables, vfork and posix_spawn, exec (see A Linux process from fork() to reaping), thread-local storage, user-space threads (green threads, goroutines, Java virtual threads), and how a multithreaded program should fork() (only the calling thread is copied, so locks held by other threads stay locked).