The two ways to make your program use more than one core.
By 1990, the kernel had given UNIX programmers a clean answer to
"how do I run two things at once": fork. The child
process ran in parallel with the parent, with its own address
space, its own file descriptors, its own everything
(Chapter 4). That separation was exactly the problem when the two processes wanted to cooperate.
A parent that wanted to share a million-element array with its
child had to copy it across the kernel boundary with every
message, or set up by hand a region of memory both could see.
POSIX 1003.1c, the threads part of the POSIX standard for UNIX-style systems, ratified in 1995, codified the
alternative, the thread: a separate stream of
execution that can be scheduled onto a different core and
shares the address space of its parent process, so it can
read and write the same memory directly. Threads are the cheap,
fast, dangerous answer to "how do I use more than one core";
processes are the slow, safe one. The next thirty years of
concurrent programming are about the price of the danger.
Threads share one memory, so a value one writes the others can read at once, and a bug in one can damage all of them. Processes each have their own memory: nothing passes until one sends a message through the kernel, which is slower but walls each one off.
How it works
- What is shared. Threads share the heap, globals and open files; each has its own stack and registers. Processes share nothing unless they set up shared memory or talk through the kernel.
- The costs. Creating a thread or switching between threads of one process is cheaper than doing the same for processes, which need their own page tables (Chapter 4).
- Who uses which. C, C++, Java and Rust default to threads; browsers put each site in its own process for safety.
Where parallelism actually helps
Not every program benefits from more cores. Programs whose work naturally divides into independent chunks (image filters processing pixels, web servers handling unrelated requests, simulations that run many independent random trials) are called embarrassingly parallel and scale almost linearly with core count. Programs whose steps depend on each other benefit much less, or not at all. Amdahl's law (Section 05) gives the mathematics; the practical point is that adding cores is no substitute for serial speed. A 64-core server runs a purely sequential program no faster than a 1-core machine with the same clock speed.
Two threads · one variable · everything depends on luck.
The price of shared memory is the lost addition of Chapter 4, now
between threads: it shows up the first time two threads modify the
same variable concurrently. Two threads each increment a shared
counter a million times. The expected result is two million. The
actual result is nearly always well short of it and changes from run
to run, depending on how the threads happen to be scheduled. In
source code the increment counter++ looks
atomic (one indivisible step), but underneath it is three
operations: read the value into a register, add one, write it back.
If two threads' three-step sequences overlap, the writes can stomp on
each other. That is a race condition: the result
depends on which thread gets there first.
counter++ looks like one step but is three: load, add, store. When thread 2 loads before thread 1 has stored, both add one to the same 100 and both store 101, so one increment is lost.
How it works
- Why it hides. The bug depends on which instructions interleave, so it rarely shows in testing and appears under load.
- The fixes. Make the read-modify-write one indivisible step (an atomic increment, Fig 16.4) or let only one thread in at a time (a mutex, Fig 16.3).
The mutex · serialise the critical section
The classic fix is a mutex (mutual exclusion lock; Chapter 4's semaphore set to 1 is one).
Each thread, before touching the shared counter, calls
mutex.lock(); after touching it, calls
mutex.unlock(). Only one thread can hold the lock at
a time; any other thread that calls lock() blocks
until the holder releases. The region between lock and unlock is
the critical section, and all critical sections
that touch the same data are serialised. The mutex itself is built
on atomic CPU instructions (such as compare-and-swap, Section 03), which the hardware guarantees are indivisible.
// the race (an unoptimised build shows it): counter ends well short of 2,000,000, and differs from run to run int counter = 0; void worker() { for (int i = 0; i < 1'000'000; ++i) ++counter; // three steps: LOAD; ADD; STORE } // the fix: std::mutex serialises the critical section std::mutex m; int counter = 0; void worker() { for (int i = 0; i < 1'000'000; ++i) { std::lock_guard<std::mutex> g(m); ++counter; // only one thread holds the lock at a time } // RAII: g's destructor releases the lock } // or, for this specific case, an atomic: no lock at all, single CPU instruction std::atomic<int> counter = 0; void worker() { for (int i = 0; i < 1'000'000; ++i) counter.fetch_add(1, std::memory_order_relaxed); // relaxed: atomic, with no ordering promise (Section 03) }
Deadlock · the trap when locks compose
Locks solve the race; they introduce a new failure mode. Deadlock happens when two or more threads each hold a lock another one needs, and all of them wait forever. The canonical recipe: thread A holds lock L1 and tries to acquire L2; thread B holds L2 and tries to acquire L1. Each waits for the other, and the program freezes. Deadlock is mathematically characterised by a cycle in the "wait-for" graph: an arrow from each waiting thread to the thread whose lock it wants. The standard prevention is lock ordering: every thread acquires locks in the same global order, which keeps the wait-for graph acyclic, and with no cycle there is no deadlock.
Left, a deadlock: each thread holds one lock and waits for the other's, and the waits close a cycle. Right: both ask for L1 first, so B waits until A has finished with both locks, and no cycle can form.
How it works
- Lock ordering. Large codebases such as Linux and PostgreSQL document a hierarchy of locks; taking them out of order is a review red flag, and Linux's lockdep checks for it at run time.
- Livelock. The same shape with threads that keep backing off and retrying in step instead of blocking: busy, yet no progress.
Therac-25, 1985–87. The first known software-caused medical fatalities were race conditions. The Therac-25 radiation therapy machine used a single computer to control beam intensity and targeting. Its predecessors had hardware interlocks (physical switches that blocked the beam in an unsafe setting); the engineers had concluded the software was reliable enough to remove them. A specific keystroke sequence, fast enough to interleave with the beam-mode-select state machine, could leave the machine in high-power X-ray mode while the turntable that shapes the beam was set for low-power electron mode. Six patients received radiation overdoses of roughly a hundred times the prescribed dose; three died.
Concurrency without locks · the deeper rabbit hole.
Locks have a cost beyond their nanosecond overhead: they serialise. A queue protected by a single mutex can be used by one thread at a time regardless of how many cores are available. In high-performance contexts (kernels, databases, garbage collectors), engineers reach for lock-free data structures: queues, stacks, hash tables built so that multiple threads can read and write concurrently without ever blocking each other. The mechanism is the same atomic CPU instruction that mutexes are built on, used directly: compare-and-swap (CAS). With CAS, a thread can perform an "if the value is still x, change it to y; otherwise tell me what it is now" operation as a single indivisible step. Built carefully, CAS-based data structures can be wait-free (every thread always makes progress) or lock-free (the system as a whole always makes progress, even if individual threads can starve). Maurice Herlihy's 1991 paper Wait-Free Synchronization made the stricter idea precise, proving a hierarchy of which data structures can be implemented wait-free on which atomic primitives and turning an engineering practice into a mathematical theory. The cost is that the code is hard to write correctly, and reasoning about it requires understanding memory orderings, the rules by which writes from one core become visible to others.
Compare-and-swap stores a new value only if the old one is still there, in one indivisible step. Thread 2's swap fails because thread 1 got there first, so it reads again and retries: both increments land, and no thread ever waits on a lock.
How it works
- In hardware.
lock cmpxchgon x86; on ARM a load-exclusive/store-exclusive pair, or a single CAS instruction since ARMv8.1. Languages expose it ascompare_exchange(C++, Rust) orcompareAndSet(Java). - The price. Under heavy contention threads keep losing and retrying; and a value that changes from A to B and back to A fools the check (the ABA problem).
- What it builds. Locks themselves, lock-free queues and stacks (Michael and Scott's queue, 1996), and reference counts.
Memory orderings · why the world is not sequentially consistent
On many modern multi-core CPUs, the order in which one core's writes become visible to another core can differ from the order they were issued. Out-of-order execution (Chapter 1, Section 04) and store buffers (holding areas where a core parks its writes until the cache takes them, which can let a later write out before an earlier one) let writes appear reordered. On a weakly ordered CPU (one that allows this freely), such as ARM, a thread that writes x = 1; flag = true; may have another thread on a different core observe flag = true
while still reading x = 0. Programs that depend on
cross-thread ordering must say so, by annotating each atomic
operation with a memory ordering (a contract about what
other cores are guaranteed to see). The pair that repairs the
broken example: the writer tags its flag store release (a
promise: every write before it on this thread, the x = 1
included, is visible by the time the flag is); the reader tags its
flag load acquire (the matching demand: no later load on
this core, the load of x included, may run ahead of the
flag load, so everything the releaser promised is visible by then). Release
and acquire meet at the flag, and x crosses with it.
Above the pair sits sequentially consistent (the
strongest ordering: one global order every thread agrees on,
priced accordingly); below it sits relaxed, which
promises atomicity and nothing else.
A writer sets x, then a flag; a reader waits for the flag, then reads x. With relaxed ordering a weakly ordered CPU can show the flag first and the reader gets the old x; a release store on the flag paired with an acquire load guarantees x arrives with it.
How it works
- Hardware differs. x86 keeps stores in order, so this test passes there even with relaxed atomics, and the bug surfaces only on weakly ordered CPUs such as ARM, POWER or RISC-V; the compiler may reorder the two writes on any machine unless told not to.
- Defaults. Java and Go atomics are sequentially consistent; C++ lets the programmer choose per operation and defaults to sequentially consistent; Rust makes every atomic operation name its ordering.
The other shape of parallelism · thousands of small cores doing the same thing.
The CPU we have built (out-of-order execution, branch prediction, caches, all the machinery of Chapter 1) is optimised for fast sequential execution: do one thing, do it as fast as possible, then move to the next. The GPU is the opposite: thousands of small cores (GPU makers call them lanes), grouped so that each group runs in lockstep, executing the same instruction on different data. It grew out of 3D graphics, where every pixel on a million-pixel screen needs the same shading calculation (working out its colour) applied to different inputs. Around 2007, NVIDIA realised that this hardware was useful for any embarrassingly parallel computation and exposed it through the CUDA programming model. Today GPUs train and run most large machine-learning models, render essentially all real-time graphics, and carry a growing share of scientific simulation.
A CPU spends its transistors on a few cores that run one stream of instructions fast; a GPU spends them on many simple lanes that run the same instruction on different data. On 64 pixels the slower lanes finish first because they all work at once; on a long chain of dependent, branching steps the CPU wins.
How it works
- Real sizes. A desktop CPU has 8 to 24 cores; an NVIDIA H100 (SXM) has 132 streaming multiprocessors with about 17,000 lanes in all, run in groups of 32 that share one instruction (Fig 16.7).
- Where the transistors go. A CPU core spends them on out-of-order execution, branch prediction and large caches; a GPU lane has almost none of that, and hides memory delays by switching between thousands of waiting threads.
- Why it took over. Graphics, video and neural networks are the same arithmetic over huge arrays; training a network is mostly matrix multiplication, exactly the GPU's shape.
SIMD and the CUDA hierarchy
The GPU's organising principle is SIMD (Single Instruction, Multiple Data). A SIMD instruction operates on a vector of values at once: add these 32 numbers to those 32 numbers in one instruction. CPUs have SIMD too (SSE, AVX on x86; NEON on ARM), typically 128 or 256 bits wide, which holds four or eight 32-bit numbers at once. GPUs take this much further: a CUDA warp is 32 threads, one per lane, running in lockstep. Many warps are grouped into a block; many blocks make a grid; the entire grid is dispatched to the GPU as a single kernel launch (here a kernel is the function the GPU runs, not the operating system). The programmer writes code that looks like it runs on a single thread; the runtime replicates it across the grid.
A kernel launch is a grid of blocks, each block a set of warps, each warp 32 threads that execute one instruction together. When a branch splits a warp, the two halves take turns while the other waits, so divergent code runs at a fraction of the speed.
How it works
- Blocks. Threads in one block share fast on-chip memory (up to 227 KB per block on an H100) and can wait for each other; in the classic model separate blocks cannot, and share data through slow global memory.
- Memory. Registers are per thread and nearly free; global memory (HBM, 80 GB on an H100) is huge and slow per access. Fast kernels keep data in the block's on-chip memory (CUDA calls it shared memory) and have each warp read neighbouring addresses together (coalesced access).
- The model. The programmer writes one thread's code; the launch runs it for every thread of the grid, each finding its own index.
The mathematics of how much speedup more cores can possibly give you.
Adding cores to a computation is not free, and it is not unbounded. Two laws govern what you can expect. Amdahl's law (Gene Amdahl, 1967) says that if a fraction p of a program's running time is parallelisable, the sequential rest sets a hard ceiling on speedup that no number of cores can lift. The ceiling is brutal in practice: a program that spends 90% of its time in parallel work maxes out at 10×.
If a fraction p of the one-core running time parallelises perfectly and the remaining 1−p is strictly sequential, count that running time as 1. On N cores the sequential part still takes 1−p and the parallel part takes p/N, so the speedup (old time over new time) is:
S(N) = 1 / ((1 − p) + p / N)
The p/N term shrinks toward zero as you add cores; the (1−p) term does not move. So S(N) → 1 / (1 − p) as N → ∞. A program that is 95% parallel cannot exceed 20× speedup.
"The effort expended on achieving high parallel processing rates is wasted unless it is accompanied by achievements in sequential processing rates of very nearly the same magnitude."
Gene Amdahl, "Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities," AFIPS, 1967Add cores and only the parallel part of a job shrinks; the serial part stays. With 5% serial work the speedup is 5.9× on 8 cores, 15.4× on 64 and 19.6× on 1,024, and no number of cores can take it past 20×.
How it works
- Other values. 50% parallel tops out at 2×, 90% at 10×, 99% at 100×: going from 95% to 99% parallel raises the ceiling from 20× to 100×, which no number of extra cores can do.
Gustafson's law (1988) appears to contradict Amdahl's; it is actually the answer to a different question: what happens when the problem grows with the machine? Amdahl fixes the problem and asks how much faster more cores make it; Gustafson fixes the time and asks how much bigger a problem the extra cores can tackle. If problem size grows with the available compute (more particles in a physics simulation, more pixels in a rendering), the sequential portion stays roughly constant while the parallel portion scales, so the work done in the same time, compared with one core, is S(N) = (1 − p) + p·N: the serial share is done once, the parallel share on each of the N cores. It grows nearly linearly with cores. This is why GPU-driven workloads (training a bigger neural network on more GPUs) and supercomputer workloads (simulating a finer-grained physical model on more nodes) do scale to thousands of cores: the problem grew with the resources.
Amdahl keeps the job fixed and asks how much faster more cores make it: with 5% of the time serial, 15.4× on 64 cores and never past 20×. Gustafson keeps the time fixed and asks how much more work fits: the parallel part grows with the machine, so 64 cores do about 61 times the work.
How it works
- The two formulas. Amdahl: S = 1 / ((1 − p) + p/N). Gustafson: S = (1 − p) + p·N, the same as N − (1 − p)(N − 1).
- Which applies. One fixed compile, or one query you want answered sooner, is Amdahl; a bigger simulation or model in the same hour is Gustafson.
- Who. John Gustafson made the argument in 1988, after his team at Sandia ran problems scaled to 1,024 processors.
Memory is not flat any more · cache coherence is the only reason it pretends to be.
On a multi-core server, memory is not uniformly accessible. In a machine with two CPU sockets (two processor chips, each in its own socket on the motherboard), each socket has its own local DRAM. This is NUMA (Non-Uniform Memory Access): a thread that runs on socket 0 but accesses memory allocated on socket 1 pays a 50–100% latency penalty per access. Each core also has its own L1 and L2, and cache coherence is the property that those private caches still agree with one another. A hardware protocol called MESI (Modified, Exclusive, Shared, Invalid) maintains coherence by tracking the state of every cache line (the block a cache stores and moves as one unit, usually 64 bytes) on every core and ensuring that no two cores have conflicting writes to the same line. The price is cache-line ping-pong: when two cores keep writing the same line they take turns invalidating each other's copy, and every write waits for the line to cross over.
In a two-socket server each socket has its own memory: a core reaches its own quickly and the other socket's only across the link, at roughly one and a half to two times the delay. The program sees one flat memory; only the clock shows where the data lives.
How it works
- The link. Intel calls it UPI, AMD Infinity Fabric; the ratio of remote to local delay depends on the platform.
- Keeping data near. The operating system tries to run a thread on the socket that holds its memory and to allocate new memory there (first touch);
numactllets a program choose. - When it bites. A thread that drifts to the other socket, or one big allocation on the wrong node, can slow a program sharply while its results stay correct.
Every core's copy of a cache line is modified (changed here, so memory is out of date), exclusive (the only copy, unchanged), shared or invalid. A core may write only once every other copy is invalid, so a write broadcasts an invalidate and a later reader must fetch the new value.
How it works
- The rule. Many readers or one writer, never both: at most one copy is M or E, and then no other copy is valid.
- Ping-pong. Two cores writing the same line take turns invalidating each other, and every write waits for the line to move. If the two values are unrelated but share one cache line, that is false sharing; padding them apart fixes it.
- Variants. AMD adds an Owned state (MOESI) and Intel a Forward state (MESIF), so a shared line can be passed between cores without going through memory.
The cache as a side channel. In January 2018, Spectre and Meltdown (Chapter 1) turned the cache against itself: it keeps what thrown-away speculative work loaded, so timing reads of it leaks a secret across security boundaries. The mitigations needed updates to the processors' own internal code (microcode) and kernel page-table isolation (KPTI, which keeps the kernel's memory unmapped while a program runs, so every system call must switch page tables), and they left a lasting ~5–30% slowdown on syscall-heavy workloads.
Closing the chapter · seam to Chapter 17
Parallelism scales the single computer up: from one core to sixty-four, from one socket to two, from CPU to GPU. It runs against three constraints: Amdahl's ceiling on speedup, NUMA's penalty for non-local memory, and MESI's cache-line ping-pong. Writing high-performance code means designing around all three. Above the single computer are many computers, sharing no memory, communicating over an unreliable network, and having to reach agreement although each of them can fail. That is the problem of Chapter 17, governed by the mathematics of distributed consensus and the CAP theorem.