Part Two

The Software
Layer

Part I built the substrate: sand to transistor, gate to instruction. Part II opens the program that owns it. The kernel first, examined as a real piece of code. Then the languages people actually write in: C, the portable systems language UNIX itself was written in; C++, the object-oriented answer to programs grown too large for C; Python, the opposite trade that chose humans over machines. Each is a particular set of decisions about which mistakes to make easy and which to make hard.

CHAPTER 04
The Code That Owns the Machine
The kernel as a real program: scheduling, virtual memory, filesystems, IPC, and the security boundary.
CHAPTER 05
C: The Language That Built Everything
The portability problem, Ritchie and Thompson at Bell Labs, pointers as math, manual memory, undefined behaviour.
CHAPTER 06
C++: When C Needed to Think in Objects
Stroustrup, object orientation as complexity management, vtables, RAII, zero-cost abstractions.
CHAPTER 07
Python: The Language That Chose Humans Over Machines
Van Rossum 1989–91, dynamic typing, the GIL, CPython internals, the two-language strategy that won data science.
Chapter 04

The Code That
Owns the
Machine

One program stays loaded from boot to shutdown. It owns the CPU, the memory, the disks and the network, and every other program reaches them only through it. This chapter opens that program, the kernel.

TopicsScheduler · Virtual memory · Filesystems · IPC · Security
Era covered1969 → present
Listinglines 0001–0790 · cc -c kernel.c
Chapter 04 hero · The Code That Owns the Machine KERNEL Ring 0 · privileged ~30M lines of code SCHEDULER who runs next MEMORY each its own FILESYSTEM files on disk DRIVERS talk to hardware SYSTEM CALLS programs ask MESSAGES between apps every program reaches the hardware through it
01 · Anatomy

The kernel is just a program

It is easy, after Chapter 1, to think of the kernel as some abstract authority, a governing law of the machine. The Bridge at the close of Part I showed otherwise: the hardware supplies a contract (the privilege bit, the trap, the MMU, the timer, and MMIO with DMA), and on the kernel side of it is a program: instructions stored in memory and executed by the same CPU as everything else. The Linux kernel, as of 2025, is roughly 30 million lines of code, nearly all of it C. What makes it the kernel is not what it is made of but where it sits: in Ring 0, with hardware-enforced privileges no other program has.

When you press the power button, the CPU starts executing instructions from a fixed address in the motherboard's built-in firmware, historically called the BIOS and now usually UEFI. The firmware initialises hardware, finds the boot device, and loads a small program called a bootloader (GRUB on Linux, Windows Boot Manager, iBoot on Apple devices). The bootloader's only job is to find the kernel image on disk, load it into memory, and jump to its entry point. From that moment the kernel stays in memory, in charge of the machine, until it shuts down.

Monolithic vs microkernel: the great schism

Two philosophies of kernel design have been arguing since the late 1980s. A monolithic kernel puts everything in Ring 0: scheduling, memory management, filesystems, network code, and the device drivers that operate each piece of hardware. Its parts call each other directly, as functions in one program, so it is fast. The cost is safety: a bug in any part can corrupt the whole machine, and one flawed driver can hand it to an attacker. Linux and most kernels in production use are monolithic.

A microkernel puts only the absolute minimum in Ring 0, typically just message passing, basic memory protection, and minimal scheduling. Filesystems, drivers and network code run as separate programs in Ring 3 (the kernel maps just a driver's own device registers into it and passes on its device's interrupts as messages), which ask each other for work through messages the kernel carries. A bug in a driver then crashes only the driver. The cost is speed: every request between those parts crosses into the kernel and out again. Andrew Tanenbaum's MINIX is a microkernel; so is seL4, which is formally verified, meaning mathematically proven to have no kernel bugs of certain classes.

In 1992, Tanenbaum criticised the then-new Linux on a Usenet newsgroup, calling its monolithic design "obsolete" and "a giant step back into the 1970s." Linus Torvalds, its author, answered bluntly and at length. Tanenbaum was right that a microkernel contains a bug's damage; Torvalds, that a monolithic kernel was simpler and faster, and Linux ran on the machines people had. Neither design has displaced the other.

Fig 4.1 · The Tanenbaum–Torvalds debate, January 1992
Fig 4.1 · The Tanenbaum–Torvalds debate, January 1992 A newsgroup thread on comp.os.minix, 29 January 1992. Andrew Tanenbaum posts "LINUX is obsolete": "LINUX is a monolithic style system. … a giant step back into the 1970s." Linus Torvalds replies: "Your job is being a professor and researcher: that's one hell of a good excuse for some of the brain-damages of minix." Below, where the two designs ended up: microkernels such as seL4 in safety-critical systems, the monolithic Linux across servers, phones and supercomputers. $ comp.os.minix · 29 Jan 1992 From: ast (Andrew Tanenbaum) Subject: LINUX is obsolete "LINUX is a monolithic style system. … a giant step back into the 1970s" From: torvalds (Linus Torvalds) Re: LINUX is obsolete "Your job is being a professor and researcher: that's one hell of a good excuse for some of the brain-damages of minix." THIRTY YEARS ON microkernelsseL4: safety-critical monolithicLinux: servers, phones

The thread itself: Tanenbaum's opening post of 29 January 1992 and the reply from Torvalds, then twenty-two. Below, where each design runs today: microkernels in safety-critical systems, Linux in most servers, phones and supercomputers.

How it works
  1. Tanenbaum's case. Theory: a microkernel keeps drivers and services out of the kernel, so one bug cannot take the machine down. He also predicted Linux, tied to the 386, would not last.
  2. Torvalds's case. Running code: a monolithic kernel is simpler and faster, and Linux was free and worked on the machines people had.
  3. The outcome. Microkernels such as seL4 (formally verified) and QNX are used where one kernel bug is unacceptable; Linux runs servers, phones and supercomputers.

macOS sits in the middle. Its kernel, XNU, is a hybrid: a Mach microkernel core and a BSD UNIX layer sharing Ring 0, so its parts can pass messages like a microkernel's and call each other directly. iOS uses the same kernel. Android uses Linux. Windows NT began microkernel-influenced and has drifted toward monolithic design for speed.

Fig 4.2 · Monolithic vs microkernel · speed against safety
Fig 4.2 · Monolithic vs microkernel · speed against safety Two kernels serve the same file read. In the monolithic kernel the program crosses into the kernel once, the filesystem and the disk driver run inside it, and the answer crosses back: two crossings. In the microkernel the filesystem and the driver are separate user programs, so the request hops through the kernel's message passing four times each way: eight crossings. Then a driver bug: in the monolithic kernel it brings down the whole machine; in the microkernel only the driver process fails and is restarted. MONOLITHIC · Linux user kernel · Ring 0 app filesystem driver memory MICROKERNEL · seL4 user kernel · Ring 0 app filesystem driver message passing crossings: 1 crossings: 2 crossings: 1 crossings: 2 crossings: 3 crossings: 4 crossings: 5 crossings: 6 crossings: 7 crossings: 8 one driver bug: the whole machine down restarted driver fails speed: fewer crossings · safety: smaller blast

A monolithic kernel runs everything in Ring 0, so a file read crosses the boundary twice; a microkernel runs filesystems and drivers as separate programs, so the same read crosses eight times. The price buys safety: a driver bug takes down one process, not the machine.

How it works
  1. Monolithic. Scheduler, memory, filesystems, drivers and network stack share one address space in Ring 0 and call each other directly.
  2. Microkernel. Ring 0 keeps only message passing, basic memory protection and scheduling; everything else is a user process that sends its requests as messages through the kernel.
  3. The trade. Every crossing costs time, so microkernels are slower; a bug in any component outside Ring 0 cannot corrupt the kernel, so microkernels are safer.

Kernel modules: a third path

Linux softens its monolithic stance with loadable kernel modules (LKMs): drivers and filesystems compiled separately from the main kernel and loaded or unloaded at runtime with commands like insmod and rmmod. A machine can then load only the drivers its hardware needs, though a module still runs in Ring 0, where a buggy one can crash the kernel. Most graphics drivers, filesystem drivers, and hardware support on Linux ship as modules; lsmod on a running system lists hundreds of them.

Fig 4.3 · A kernel module loading at runtime
Fig 4.3 · A kernel module loading at runtime A running Linux kernel with modules already loaded beside it: a graphics driver, a filesystem and a sound driver. The command insmod my_driver.ko brings a new module into Ring 0; its calls are linked against the kernel's symbol table and its init function runs. Later rmmod reverses it: its exit function runs and the module is removed. insmod my_driver.ko.ko: a module file rmmod my_driver KERNEL · Ring 0 kernel image scheduler, memory,syscall layer symbol table graphics driver filesystem sound driver my_driver init function runs exit function runs

The command insmod loads a separately built module into the running kernel, and rmmod takes it out again. The module cannot know where the kernel's functions sit, so each one it calls is looked up by name in the symbol table, which pairs names with addresses; then its init function runs inside Ring 0.

How it works
  1. Load. The kernel allocates memory for the module, copies its code in, and finds the address of every kernel function it calls in the symbol table, by name.
  2. Start. It calls the module's module_init(); a driver registers itself with the subsystem it serves.
  3. Still Ring 0. A loaded module has the kernel's full power, so a buggy one can crash the machine (Fig 4.2).
  4. Unload. rmmod calls module_exit(), frees the memory and forgets the module. lsmod lists what is loaded.

Linux also limits the damage risky code can do: a few kinds of driver run as ordinary programs in Ring 3, where a crash takes down only that program, and the small eBPF programs of Section 06 run in the kernel only after a check proves them safe.

02 · Scheduling

The art of taking turns

A modern laptop runs hundreds of processes; a server may run thousands. Your machine has at most a few dozen CPU cores, so most processes cannot be running at any given instant. The kernel creates the illusion that they all are by switching between them many times per second, too fast for you to perceive the gaps. The component that decides which process gets the CPU next is the scheduler, one of the most-studied parts of any kernel.

Cooperative vs preemptive: who interrupts whom

Many early personal-computer systems were cooperative, the model the Bridge described: each running program yielded the CPU back to the scheduler when it chose, so one that never yielded froze the entire machine. Mac OS through version 9 worked this way, as did 16-bit Windows.

Modern systems are preemptive: the kernel takes the CPU back from a running process at regular intervals, ready or not. The mechanism is the hardware timer interrupt: the Bridge's timer in each core, which Linux sets to fire 100–1000 times a second. Each interrupt forces the CPU into Ring 0, where the scheduler runs and either resumes the current process or switches to a different one.

What "scheduling" actually has to decide

Picking a process to run sounds trivial, but each classical algorithm makes a different trade-off:

AlgorithmHow it worksTrade-off
FCFSFirst-come, first-served. Run processes in arrival order until they finish.Simple. Terrible response time: one long process blocks all the short ones (the "convoy effect").
SJFShortest Job First. When the CPU is free, pick the waiting job with the least work and run it to the end.Provably optimal for average wait when every job is ready at the start. Requires knowing job length in advance, which you usually don't.
Round RobinEach process gets a fixed time slice ("quantum"). When it expires, move to the next.Fair. Responsive. Doesn't optimise for total throughput.
PriorityEach process has a priority number. Higher priority runs first.Lets the system favor critical work. Risk: low-priority processes can be starved indefinitely.
MLFQMultilevel Feedback Queue. Multiple priority levels; processes that use a lot of CPU drift to lower priorities, processes that wait often rise.Approximates SJF without knowing job length, when short jobs mix with long ones. Windows and classic UNIX schedulers descend from it.
CFSCompletely Fair Scheduler. Linux's default 2007–2023. Tracks how much CPU each process has used; always runs whichever has used the least.Approximates "everyone runs at exactly 1/N speed on N processes." A red-black tree makes each decision take one more step each time the number of processes doubles, written O(log N).

Linux's CFS keeps every runnable process in a tree sorted by virtual runtime, a measure of how much CPU time the process has had, weighted by its priority: a high-priority process's virtual runtime grows more slowly, so it is picked more often. Smaller values sit to the left, and the leftmost process, furthest behind, runs next. After its turn it goes back in further right; one that stops to wait leaves the tree until it is ready. It is a red-black tree: each step down from the top rules out about half the processes still in play, so even thousands are reached in a dozen or so steps, and after each change the tree rearranges a few nodes so that no branch grows much longer than the rest. Every process thus tends toward its fair share. Since kernel 6.6 (2023), Linux's default is CFS's successor EEVDF (Earliest Eligible Virtual Deadline First), which keeps the same bookkeeping but gives every task a virtual deadline set by the slice it asks for, so a task that asks for short slices gets the CPU sooner without taking more than its share.

Fig 4.4 · CFS chooses the leftmost node of a red-black tree
Fig 4.4 · CFS chooses the leftmost node of a red-black tree Six runnable processes in a balanced search tree, keyed by how much CPU time each has had, its virtual runtime. The leftmost, 3.2, is the furthest behind, so it runs next. While it runs its virtual runtime grows until it is a little past the others, to 8.9; when its turn ends it goes back into the tree after 8.6, as a new leaf, and the new leftmost, 8.1, runs next. 8.68.117.23.28.419.8 8.68.117.28.419.8 8.68.117.28.48.919.8 leftmost: runs next leftmost: runs next ON THE CPU 3.2 4.3 5.4 6.6 7.7 8.9 virtual runtime grows 8.9 goes back in, after 8.6 scheduler picks 3.2

The leftmost, 3.2, has had the least CPU, so it runs until its virtual runtime is a little past the others, at 8.9. It goes back in after 8.6, as a new leaf; now 8.1 is the leftmost.

How it works
  1. Virtual runtime. CPU time received, weighted by priority. Whoever has had least is owed the CPU.
  2. The tree. A red-black tree, kept balanced, with the leftmost cached: removing it and inserting a process each take a few dozen steps at most, even with thousands runnable.
  3. Since 6.6. Linux's default is now EEVDF, which keeps the tree and the virtual runtime but picks the eligible task with the earliest virtual deadline, so tasks that ask for short slices are served sooner, though without the guarantee a real-time kernel gives.
Fig 4.5 · Four schedulers, one workload
Fig 4.5 · Four schedulers, one workload Four jobs: A arrives at time 0 needing 8 units, B at 1 needing 4, C at 2 needing 9, D at 3 needing 5. First come first served gives an average wait of 8.75 and an average of 8.75 before a job first runs. Shortest job first: 7.75 and 7.75. Round robin with a quantum of 2: average wait 12.75 but first run after 2 on average. MLFQ: 13 and 1.5. Short average waits and quick first responses pull against each other. JOBS A 0+8 B 1+4 C 2+9 D 3+5 FCFS A B C D avg wait 8.75 first run after 8.75 SJF A B D C avg wait 7.75 first run after 7.75 roundrobin A B C A D B C A D C A D C avg wait 12.75 first run after 2 MLFQ A B C D A B C D A C avg wait 13 first run after 1.5 0 10 20 26 time units 0+8: arrives at 0, runs 8 lowest wait: SJF · soonest first run: MLFQ

The same four jobs under four schedulers. Shortest-job-first gives the lowest average wait; round robin and MLFQ give up average wait to start every job almost at once, which is what an interactive machine needs.

How it works
  1. The jobs. "A 0+8" means A arrives at time 0 and needs 8 units of CPU. A job's wait is all the time it is ready but not running, gaps between its turns included; "first run after" counts only up to its first turn.
  2. FCFS. First come, first served: A's eight units hold up everyone behind it, the convoy effect.
  3. SJF. Shortest job first: the lowest average wait of the four here, and provably optimal when every job is ready at the start, but it needs to know job lengths in advance.
  4. Round robin. Each job runs for a quantum of 2, then goes to the back of the queue: responsive, at the cost of many switches.
  5. MLFQ. Multi-level feedback queue: a job that uses up its whole slice drops to a lower queue with a longer slice, so short jobs stay on top. Here every job uses up its slices, so none stays on top and MLFQ behaves much like round robin.
Queueing theory and Little's Law

The mathematics underlying scheduling is queueing theory, developed in the early 20th century by Agner Krarup Erlang for telephone networks. Its best-known result, proved by John Little in 1961, is Little's Law:

L = λ · W

The average number of requests in the system (L), waiting or being served, equals their arrival rate (λ) times the average time each spends there (W): 10 requests a second, each inside for 2 seconds, keeps 20 inside on average. It holds for any stable queue, one that keeps up with its arrivals, whatever their pattern. W depends on spare capacity: a server finishes μ requests a second, and only the difference μ − λ absorbs a burst of arrivals; as λ nears μ, each burst takes longer to clear than the quiet spell before the next, and backlog piles up. For the simplest queue, one server with random arrivals and random service times, W is exactly 1/(μ − λ). Divided by the service time, 1/μ, that becomes 1/(1 − ρ), where ρ = λ/μ is the utilisation, the share of capacity in use: 20 service times at 95%, 100 at 99%, five times as long for four points more load.

Fig 4.6 · Little's Law and the cliff at full utilisation
Fig 4.6 · Little's Law and the cliff at full utilisation A queue in front of one server, at three loads. At 50% utilisation one request is in the system on average, waiting or being served, and each spends twice the service time there. At 80%, four are in the system and the time is five times the service time. At 95%, nineteen are in it and the time is twenty times. Below, the curve W = 1/(1 − ρ) climbs steeply toward 100%. Little's Law, L = λW, links the three numbers. QUEUE → SERVER utilisation 50%L = 1W = 2 utilisation 80%L = 4W = 5 utilisation 95%L = 19W = 20 L (in system) = λ (arrival rate) · W (time) 5 20 0 50% 80% 100% W = 1/(1 − ρ) utilisation ρ

Little's Law ties a queue's length to its arrival rate and wait: L = λW, for any stable queue. As utilisation nears 100% the time in the system grows like 1/(1 − ρ): five times the bare service time at 80% load, twenty times at 95%.

How it works
  1. The law. Average number in the system = arrival rate × average time in it. It needs no assumption about how arrivals or service times are distributed.
  2. The curve. For the simplest queue (random arrivals and service times, one server, called M/M/1) the time in the system is the service time divided by 1 − ρ.
  3. The cliff. From 50% to 60% load the time in the system grows by a quarter; from 90% to 95% it doubles; at 100% (the dashed line) the queue never drains. That is why operators worry once a server stays above about 80%.

Real-time scheduling: when missing a deadline kills

General-purpose schedulers like CFS and EEVDF optimise for average performance and guarantee nothing about the worst case. That is fine for most software and catastrophic for an anti-lock braking system or a pacemaker. These systems run on real-time kernels: variants of Linux (PREEMPT_RT) or specialised OSes (VxWorks, QNX, FreeRTOS) that guarantee a task will run within a bounded time after it becomes ready, even under load. Liu and Layland analysed the two classic rules in 1973: rate-monotonic scheduling, in which the task that repeats most often gets the highest priority, and earliest-deadline-first.

Fig 4.7 · A periodic task and the deadline it must meet
Fig 4.7 · A periodic task and the deadline it must meet A brake controller that runs at 100 Hz: every 10 milliseconds a new job starts and must finish before the next one. The first three jobs take 5 milliseconds and finish with time to spare. The fourth takes 12 milliseconds and is still running at its 40 millisecond deadline: a missed deadline, which in a brake controller is a failure, not a slowdown. 100 Hz BRAKE CONTROLLER · 10 ms PERIOD deadline 5 ms 5 ms 5 ms missed by 2 ms 12 ms 0 10 20 30 40 ms

A real-time task must finish each job inside its period: here every 10 ms. Three jobs fit with room to spare; the fourth runs 12 ms and is still going at its deadline, which in a brake controller is a failure, not a stutter.

How it works
  1. The guarantee. Rate-monotonic scheduling meets every deadline if total utilisation stays under about 69%; earliest-deadline-first, up to 100% (Liu and Layland, 1973).
  2. The overrun. A job that takes longer than its budget breaks the arithmetic, which is why real-time code is written to bounded, measured worst cases.
03 · Virtual memory

The lie of unlimited memory

Chapter 1 introduced virtual memory, the private address space every process believes it owns, and the Bridge showed the silicon that enforces it: the MMU walking the page tables, the TLB remembering its answers. The kernel's side is the tables themselves, which it builds and keeps current, and what happens when the MMU finds no usable entry and calls on the kernel.

The translation problem

Every load and store uses a virtual address, which the MMU of Fig BR.4 translates to a physical address through the page table the kernel builds and keeps current.

Memory is divided into fixed-size blocks called pages, typically 4 KiB: virtual pages in the address space and physical pages in RAM. The page table maps one to the other. When a virtual page has no physical page in RAM (it has never been used, was moved to disk to free RAM, or belongs to a file mapped into memory but not yet read), its entry is marked invalid, and the MMU raises a hardware exception called a page fault. The kernel's page-fault handler decides what to do.

Why the page table cannot be flat

A flat page table would hold one entry for every possible virtual page. On 64-bit x86 a virtual address has 48 usable bits, enough for 248 bytes (256 TiB); split into 4 KiB (212-byte) pages, that is 236 pages. At 8 bytes an entry, the table alone would need 512 GiB per process.

The solution, as the Bridge showed, is a multi-level page table: a tree of small tables, each one 4 KiB page of 512 entries. An address's last 12 bits are the page offset, the byte's place inside its page; the 36 bits above form four 9-bit indexes (29 = 512), and each picks an entry on one level that points to the table below, the last to the physical page. Most of a process's 256 TiB is unused, and an empty entry near the top means no tables exist beneath it, so a real process needs a few megabytes of tables, not 512 GiB.

Fig 4.8 · x86-64 four-level page table walk
Fig 4.8 · x86-64 four-level page table walk The 48-bit virtual address 0x7FFA_0A32_C5E8 split into four 9-bit table indexes (255, 488, 81, 300) and a 12-bit page offset (1512). The CR3 register holds the address of the top table. Entry 255 of the level-4 table points to a level-3 table, its entry 488 to a level-2 table, entry 81 to a level-1 table, and entry 300 to the physical page, where the offset picks the byte. Four table reads and then the data: five memory reads for one load. VIRTUAL ADDRESS 0x7FFA_0A32_C5E8 L4 255 9 bits L3 488 9 bits L2 81 9 bits L1 300 9 bits offset 1512 12 bits CR3 L4 L3 L2 L1 page 4096 bytes 512 entries each reads L4 L3 L2 L1 data

The CPU splits a 48-bit address into four 9-bit indexes and a 12-bit offset, then follows CR3 down a tree of four tables, one index per level. Four table reads come before the data itself: five memory reads for one load, unless the TLB already holds the answer.

How it works
  1. Why 9 bits. A table is one 4 KiB page of 8-byte entries, so it holds 512 of them, and 29 = 512. The last 12 bits address a byte inside a 4 KiB page.
  2. The pointers. CR3 holds the physical address of the top table; each entry holds the physical address of the next table down, plus permission bits.
  3. Sparse by design. Tables exist only for regions the process uses, so a few megabytes of tables map a whole program. Newer CPUs can add a fifth level for 57-bit addresses.

The TLB: a cache for translations

Walking all four levels on every access would turn each load into five memory reads, so the Bridge's Translation Lookaside Buffer (TLB) answers first, and a hit skips the walk. The cost the kernel has to manage is the miss: up to four extra memory reads, after which the MMU stores the result in the TLB.

A switch to another process drops the old translations unless each entry is tagged with its address space (PCID on x86), and even tagged, the processes compete for the same few entries.

Page faults: the productive kind

A page fault sounds like an error, but most are not: they are how the kernel implements many of its most useful features. Faults fall into three rough categories:

A minor page fault happens when no disk read is needed: the data is already in RAM but this process's page table has no entry for it yet (a page of a shared library another program has already loaded, say), or the page has never been used and the kernel simply hands over a fresh page of zeros. The kernel just adds the entry and returns. Cost: microseconds.

A major page fault happens when the data is not in RAM and must be fetched from disk: the page was moved to disk to free RAM, or the program is first reading part of a mapped file. The kernel issues a disk read, suspends the process, and resumes it when the data arrives. Cost: milliseconds, roughly a thousand times slower than a minor fault.

An invalid page fault happens when the access truly is illegal: writing to a read-only page, using an unmapped address such as 0 (NULL), executing data marked non-executable. The kernel sends the offending process a SIGSEGV signal (Section 05 covers signals), which by default ends the program with the famous "segmentation fault" message.

Fig 4.9 · A page fault, decision tree
Fig 4.9 · A page fault, decision tree Three memory accesses fault and reach the kernel handler, which asks two questions. Is the address mapped and the access allowed? If not, the fault is invalid: the process gets SIGSEGV, as a write through a NULL pointer does. If it is legal, is the data already in RAM? If yes, a minor fault: the kernel only fills in the page-table entry and resumes the process in microseconds, as for a shared-library page already cached. If no, a major fault: the kernel reads the page from disk, which takes milliseconds, as the first read of a memory-mapped file does. Each exit box names the access that ends there, in that access's colour. cached page mmap read write to NULL page fault: kernel handler mapped address, access allowed? no yes page in RAM? yes no minor map it microseconds cached page major read disk milliseconds mmap read invalid SIGSEGV dies write to NULL

Most page faults are not errors. The kernel asks two questions, and three accesses take the three exits: a cached page is mapped in microseconds, a first read of a mapped file waits milliseconds for the disk, and a write through NULL ends in SIGSEGV.

How it works
  1. Minor. The data is already in RAM (a page another process has loaded, the page cache, or a fresh zero-filled page), so the kernel only writes the page-table entry.
  2. Major. The kernel starts the disk read, runs other processes while it waits, then maps the page and resumes: about a thousand times slower than a minor fault.
  3. Invalid. The address is unmapped or the access breaks the mapping's permissions; unless the process catches SIGSEGV, it dies with "segmentation fault".

Two beautiful uses of page faults

Memory-mapped files. When you call mmap() on a file, the kernel doesn't read the file. It sets up page table entries marking the relevant virtual addresses as backed by that file, but invalid (not yet present). The first time you touch a page, you take a page fault, and the kernel reads just that one page from disk. Reading a 100 GB file as if it were one unbroken stretch of memory becomes trivial, because only the pages you touch are loaded. This is how some databases (LMDB) and search engines (Lucene) handle large data, and how every shared library loads on Linux.

Copy-on-write fork. When a UNIX process calls fork() to create a child process, the child receives a copy of the parent's entire address space, which done naively would mean duplicating gigabytes of memory. Instead the kernel marks all of the parent's pages read-only in the page tables, keeps its own note that they are really writable, and shares them with the child. Only when one of them tries to write does a page fault occur, and because of that note the kernel copies that one page instead of ending the process. Most pages are never written: a new program is launched by a fork followed at once by exec, which swaps in the new program's memory, so almost nothing is copied.

Fig 4.10 · Fork, then write: when shared pages diverge
Fig 4.10 · Fork, then write: when shared pages diverge The parent's and the child's page tables after fork both point at the same three physical pages, A, B and C, all marked read-only, so nothing has been copied. The child writes to page B. The write faults; the kernel copies B into a new physical page and points the child's entry at the copy, now read-write. Pages A and C stay shared, and the parent never notices. parent RAM child page A A A read-only read-only page B B B read-only read-only writable page C C C read-only read-only copy of B fork(): shared, nothing copied child writes B: page fault kernel copies B child gets its own B; 1 page copied

After fork() the two page tables point at the same pages, all read-only, and nothing is copied. When the child writes page B, the fault makes the kernel copy that one page and give the child its own; A and C stay shared.

How it works
  1. The trap. Marking shared pages read-only turns the first write into a page fault, so the kernel learns exactly which page needs copying, when.
  2. The parent's side. If the parent writes B later, the kernel sees that nobody else maps it any more and simply makes it writable again, without a copy.
  3. Fork, then exec. A child that calls execve() straight away replaces its address space before writing anything, so launching a program copies almost no pages.

Dirty COW (CVE-2016-5195). For at least nine years a race in Linux's copy-on-write code let any user write to read-only files such as /etc/passwd, the machine's list of accounts, and so make any account the administrator. The attacker mapped the file privately, so writes should go to a private copy, then ran two loops at once: one wrote, the other kept telling the kernel to discard the copy. The writes went through /proc/self/mem, a file through which the kernel writes into a process's own memory for it, and a discard that fell between the copy and the write made it look the page up again and find the file's own page, so the write went into the file.

Fig 4.11 · Dirty COW: the race that broke Linux
Fig 4.11 · Dirty COW: the race that broke Linux Copy-on-write promises that a write lands in a private copy and never in the file underneath. A timeline from 2005 to 2017: in 2005 a fix for the race is made and undone within days; Linux 2.6.22 of 2007 is the oldest kernel the advisories list, and the flaw is in every kernel for nine years or more; in October 2016 it is seen in use and patched within hours; in 2017 a related flaw turns up in the huge-page code. copy-on-write promises: a write lands in a private copy, never in the file underneath 2005 a fix for this race, undone 2007 2.6.22: oldest in the advisories 2016 seen in use: patched in hours 2017 a related flaw, huge pages nine years or more, in every kernel

Dirty COW broke copy-on-write's one promise, that a write lands in a private copy and never in the file. It shipped in Linux for at least nine years, was patched within hours of being seen in use in October 2016, and a related flaw followed in the huge-page code a year later.

How it works
  1. The fix. The kernel now remembers that it made the private copy and allows the write only if the page-table entry is still that private copy, instead of retrying the look-up as a plain read.
  2. The class. A check, then an action that relies on it, with a moment between them in which something else can change: time-of-check to time-of-use, closed by making the two one step or checking again.
  3. Getting the patch out. A fix protects only machines that install it; distributions shipped it within days, Android in its December 2016 security bulletin.
04 · Filesystems

A tree of names on a flat array of blocks

A disk, at its lowest level, is a long row of numbered, fixed-size blocks: a spinning hard drive presents itself to the OS as billions of 512-byte sectors, and an SSD presents the same picture over entirely different hardware; the filesystem groups them into blocks, typically 4 KiB. From these blocks the filesystem builds the structure you actually use: named files in directories nested into a tree, each file recording its owner, permissions and timestamps, none of which the disk itself knows about.

The inode: where a file actually lives

In UNIX-derived filesystems, the central data structure is the inode, short for "index node." Every file has exactly one inode, and the inode contains everything about the file except its name and its data:

sizefile size in bytese.g. 24,873
owneruser ID and group IDuid, gid
permread/write/execute permissionsrwxr-xr--
timeschanged, modified, accessedctime, mtime, atime
linkshow many filenames point herelink count
blocksarray of disk block numbers holding the data12 direct + indirect

Notice what's not there: the filename. In UNIX, a filename is a property of the directory it lives in, not the file. A directory is itself a special kind of file whose contents are a list of (name, inode-number) pairs. To open /home/yki/notes.txt, the kernel looks up the inode for /, reads its directory contents to find the entry for home, follows that to the next directory's inode, and so on, until it reaches notes.txt's inode. Then it uses the inode's block list to read the actual data.

Because the name lives in the directory, a single file can have several names: multiple directory entries pointing to the same inode, called hard links. The file is only deleted when its link count drops to zero. For the same reason, renaming a huge file is instant: only the directory entry changes. The inode and its data don't move.

Fig 4.12 · Filename → inode → data blocks
Fig 4.12 · Filename → inode → data blocks The directory /home/yki is a list of names and inode numbers: notes.txt 4827, draft.txt 4828, photo.jpg 4829, backup.txt 4827. notes.txt and backup.txt hold the same inode number, so they are one file with two names, a hard link. Inode 4827 records the size (18,400 bytes), the permissions, a link count of 2, and the data blocks 102, 103, 104, 500 and 501, which sit in two separate places on the disk. Opening either name reaches the same inode and the same blocks. DIRECTORY /home/yki notes.txt → 4827 draft.txt → 4828 photo.jpg → 4829 backup.txt → 4827 inode 4827 18,400 bytes · permissions links 2 · blocks 102–104, 500–501 101 102 103 104 105 … 499 500 501 502 DISK BLOCKS open notes.txt → inode 4827 open backup.txt → same inode two names, one file: a hard link

The name lives in the directory and the file lives in the inode: notes.txt and backup.txt hold the same inode number, so they are one file with two names. The inode lists the file's blocks, which can sit far apart on the disk.

How it works
  1. Lookup. Opening /home/yki/notes.txt reads one directory per path component, from / down, until the last name gives the inode number.
  2. Hard links. The link count says how many names point at the inode. Deleting a name lowers it; the data is freed only when it reaches zero.
  3. Runs of blocks. Modern filesystems (ext4, XFS, APFS) record runs such as 102–104 instead of every block number, and try to keep a file's blocks together.

The everything-is-a-file principle

Chapter 1's creed, everything is a file, rests on the inode idea: UNIX gave devices and the kernel's own state names in the same tree. Most kernel-managed resources are reached this way:

  • Your keyboard is one of the /dev/input/event* files.
  • Random numbers come from /dev/urandom.
  • The state of every running process lives under /proc/<pid>.
  • Hardware sensors expose temperature and voltage as files in /sys.

None of these has data blocks on the disk: a read or write on one runs kernel code (a driver, for a device) that supplies or accepts the bytes. The same four system calls work on all of them: open, read, write, and close. This uniformity is one of the reasons UNIX-derived systems became so dominant: tools written for files work unchanged on devices and process state. The command that counts the lines of a text file, wc -l, counts them just as well in a sensor's readings under /sys or a process's records under /proc.

Journaling: the database technique that saved filesystems

A filesystem that simply writes data and metadata (the inodes, directories and free-block lists that keep track of it) to disk wherever convenient has a fatal weakness: power loss. If the machine crashes between writing data and updating its corresponding metadata (or vice versa), the filesystem becomes inconsistent: files exist whose blocks are still listed as free, or directory entries point at non-existent inodes. The classic UNIX response was fsck, a program that scans the entire disk after a crash to find and fix inconsistencies. On a multi-terabyte disk this could take hours.

The modern solution, borrowed from database theory, is the journal, a small, dedicated region of the disk where every change to the filesystem's own bookkeeping (inodes, the free list, directories) is written first, then applied to the main filesystem. Each change in the journal ends with a commit record, written last; after a crash, the kernel replays the changes that have one and discards those that do not. By default ext4 writes file contents to their place before the commit, so a committed change never points at missing data. Each change lands whole or not at all, and recovery takes seconds instead of hours. ext4 (Linux), NTFS (Windows), and HFS+ (older macOS) all journal. The technique is called write-ahead logging; we'll meet it again in Chapter 13.

Fig 4.13 · Journaling: write your intent before doing it
Fig 4.13 · Journaling: write your intent before doing it A journal region and the main filesystem. A change to an inode, the free list and a directory is first written to the journal, then sealed with a commit record. The power is cut. On boot the kernel finds the commit record and replays the change into the main filesystem, which moves from version 1 to version 2. A second change is logged, but the power is cut before its commit record is written; on boot the kernel discards it, and the main filesystem stays at version 2, consistent either way. JOURNAL MAIN FILESYSTEM inode v2 free v2 dir v2 COMMIT inode v3 free v3 inode v1 v2 free list v1 v2 directory v1 v2 log the change commit: now it is durable power cut boot: commit found, replay v2 log the next change: v3 power cut before commit boot: no commit, discard v3, keep v2

One change is committed and replayed after the power cut, taking the filesystem from version 1 to 2; the next loses power before its commit record and is discarded, so the filesystem stays at version 2.

How it works
  1. The atomic step. The commit record is one small block, written after the rest of the entry has reached the disk. Whether it exists decides the outcome.
  2. What is logged. By default ext4 and NTFS journal metadata only (inodes, the free list, directories); ext4's data=journal mode also logs file contents, writing them twice.

The newest generation goes further: copy-on-write filesystems like ZFS, btrfs and (for its metadata) Apple's APFS never overwrite existing data. Their blocks hang in a tree from one root, each block holding the addresses of the blocks below it, so a change goes to new blocks, with a new parent for each (the parent holds the address that changed), up to a new root; one final pointer switch to that root makes it current, so a crash leaves either the old version or the new one whole. Keeping the old blocks makes snapshots, saved views of the whole filesystem at one moment, nearly free.

Fig 4.14 · Copy-on-write filesystem: never overwrite, just re-point
Fig 4.14 · Copy-on-write filesystem: never overwrite, just re-point A file's tree: a root points to a node, which points to blocks 102 and 103. To change block 103 the filesystem writes a new block 103′ elsewhere, then a new node pointing at 102 and 103′, then a new root. One pointer switch makes the new root current. Nothing was overwritten: the old root still points at the old node and block 103, so it is a snapshot of the file before the change, and block 102 is shared by both versions. root node 103 102 103′ node′ root′ current currentsnapshot shared change block 103 write 103′ somewhere new write a new parent write a new root switch one pointer: done

A copy-on-write filesystem never overwrites a block: changing 103 writes a new 103′, a new parent and a new root, and one pointer switch makes the change live. The old root still describes the old file, so a snapshot costs nothing until the versions diverge.

How it works
  1. Crash safety. Before the switch the old tree is whole; after it the new one is. A crash before the switch loses only the change, never the filesystem.
  2. Checksums. Every block can carry a checksum checked on each read, so silent disk corruption is caught. ZFS and btrfs cover data too; APFS covers metadata.
  3. The cost. One changed byte rewrites a block at every level up to the root: write amplification. NetApp's WAFL made this trade in the early 1990s; ZFS, btrfs and APFS make it now.
05 · IPC

How processes talk

Processes are isolated by design: virtual memory and privilege separation exist so that one process cannot read or modify another's memory. But isolation taken to its extreme produces a useless system: programs that share nothing cannot pass each other data, or even say when a job is done. The kernel therefore exposes a controlled set of mechanisms for processes to communicate, collectively called inter-process communication, or IPC.

The kernel offers eight mechanisms:

MechanismHow it worksBest for
PipeA unidirectional byte stream between two related processes. The shell | operator creates one.Chaining commands together. The classic UNIX pipeline.
Named pipe (FIFO)A pipe with a filesystem name. Any process with permission can connect.Letting unrelated processes communicate via a known path.
SignalA small numeric notification sent to a process. SIGTERM, SIGKILL, SIGINT (Ctrl+C).Asynchronous control: "stop," "reload config," "we're shutting down."
Shared memoryTwo processes map the same physical pages into both their address spaces.The fastest IPC. No copying. Used by databases and high-performance systems.
Message queueA kernel-managed queue of typed messages between processes.Structured communication; less common today.
SemaphoreA counter that processes change in one indivisible step, used to coordinate access to a shared resource.Synchronising without sharing data: "is it my turn yet?"
UNIX socketLike a network socket, but local: kernel-mediated, with proper authentication.Modern desktop IPC. Used by Docker, systemd, X11, Wayland.
Network socketTCP or UDP connection, possibly to another machine.Distributed systems. We'll cover these in depth in Part III.

The pipe: UNIX's most beautiful idea

The pipe reached UNIX in 1973: Doug McIlroy had been urging the idea for years, and Ken Thompson finally built it in an evening. A pipe is a kernel-allocated ring buffer, a fixed block of memory filled and emptied in a circle, with two file descriptors attached: one process writes to one end, another reads from the other. When the buffer is full the kernel makes the writer wait, and when it is empty the reader; the two programs share nothing but the stream of bytes.

Fig 4.15 · A pipe is a kernel ring buffer with two ends
Fig 4.15 · A pipe is a kernel ring buffer with two ends A pipe drawn as a ring of eight cells in the kernel. The writer, cat server.log, puts bytes in at the write pointer, which steps round the ring; the reader, grep ERROR, takes them out at the read pointer, which follows it. When the ring is full the writer is blocked until the reader frees a cell; when the ring is empty the reader is blocked until the writer adds one. A real pipe on Linux holds 64 KB by default. writer cat server.log reader grep ERROR blockedfull blockedempty kernel buffer E R R O R : · d i s k write write write write write write write write write write write write read read read read read read read read read read read read

cat writes at one pointer and grep reads at the other; the ring fills when grep falls behind and empties when it catches up, and the kernel makes whichever side is ahead wait.

How it works
  1. Two descriptors. pipe() returns one file descriptor for each end; the shell connects cat's output to one and grep's input to the other.
  2. The size. A Linux pipe holds 64 KB by default, so a fast writer runs ahead of a slow reader by at most that much.

In the shell, a pipe is written as a single character:

shell
# Count how many unique lines start with "ERROR" in a log:
cat server.log | grep "^ERROR" | sort | uniq | wc -l

# Five separate processes, each doing one small thing.
# The shell creates pipes between them. Output of one
# becomes input of the next, streamed byte-by-byte.

Each program knows nothing about the others: it reads its standard input and writes its standard output; these are two file descriptors every program starts with, normally the keyboard and the screen, and the shell points them at the ends of pipes. All five run at once, each streaming into the next as fast as both can go. That is one reason UNIX produced a culture of small composable tools rather than monolithic applications.

Fig 4.16 · The UNIX philosophy, in five processes
Fig 4.16 · The UNIX philosophy, in five processes Five processes joined by pipes: cat server.log, grep ^ERROR, sort, uniq, wc -l. cat streams five log lines; grep passes the three ERROR lines as each arrives; sort must wait until its input ends and then prints all three in order; uniq drops the repeated "ERROR disk"; wc -l prints 2. cat server.log read the file INFO start ERROR disk ERROR net WARN cpu ERROR disk pipe grep ^ERROR keep ERROR lines ERROR disk ERROR net ERROR disk pipe sort waits for end ERROR disk ERROR disk ERROR net pipe uniq drop repeats ERROR disk ERROR net pipe wc -l count lines 2

Five small programs joined by four pipes run at once, each reading standard input and writing standard output with no idea what is on the other end. Lines stream through as they arrive, except at sort, which must see the last line before it can print the first.

How it works
  1. The wiring. The shell calls pipe() four times and fork() five, then points each program's standard output at the next one's standard input.
  2. Streaming. grep and uniq work a line at a time; sort and wc must reach the end of their input before they can answer.

"This is the Unix philosophy: write programs that do one thing and do it well. Write programs to work together. Write programs to handle text streams, because that is a universal interface."

Doug McIlroy, inventor of the UNIX pipe

Signals: a doorbell to a process

Pipes carry data. Signals carry no data, only the bare fact that something happened. A signal is a small, numbered notification delivered asynchronously to a process: number 2 (SIGINT) is what the kernel sends when you press Ctrl+C; number 9 (SIGKILL) is "stop now"; number 15 (SIGTERM) is the polite "please stop." A process can handle most signals (it registers a function; on arrival the kernel hijacks the process, runs the handler, then resumes), ignore them, or take the default action. Two signals, SIGKILL and SIGSTOP, can be neither handled nor ignored: the first always kills, the second always suspends.

Fig 4.17 · A signal arrives mid-execution
Fig 4.17 · A signal arrives mid-execution Three lanes over time: the program's main code, the kernel, and the program's signal handler. Pressing Ctrl+C makes the kernel mark SIGINT as pending; the program keeps running until its next return from the kernel, where the kernel diverts it into the handler; when the handler returns, the kernel restores the saved state and main resumes where it stopped. In a second run SIGKILL arrives instead: the kernel ends the process at once, and the handler never runs. main kernel handler Ctrl+C pending handler resume SIGKILL process endednever runs

Ctrl+C makes the kernel mark SIGINT pending; the program runs on until its next return from the kernel (the end of its next system call or timer interrupt), where it is diverted into its handler and then resumed where it stopped. SIGKILL never reaches the program: at that same point the kernel ends the process instead, and no handler runs.

How it works
  1. Three reactions. A process can handle a signal with its own function, ignore it, or take the default action: terminate (sometimes with a core dump), stop, continue, or nothing.
  2. Uncatchable. SIGKILL and SIGSTOP can be neither handled nor ignored, so an administrator can always stop a process that handles or ignores every other signal.
  3. Crude by design. A signal is just a number. A second signal of the same kind arriving while the first is still pending merges with it.

Shared memory and semaphores: the no-copy IPC

Pipes are convenient, but every byte through one is copied twice, from the writer's memory into the kernel's buffer and then out into the reader's memory. For two processes on one machine that exchange large amounts of data, shared memory is the fastest mechanism, because it skips both copies: the two call into the kernel to map the same physical pages into both their virtual address spaces. After that, the region is ordinary memory, and no access involves the kernel.

The price of sharing is coordination: if two processes each read a shared count of 5, add 1 and write back 6, one addition is lost. Semaphores prevent this. A semaphore is a counter changed atomically, in one step that nothing can interrupt. A process that wants in decrements the counter, or sleeps if it is 0; one that leaves increments it, waking a sleeper. Initialised to 1, a semaphore admits one process at a time, a mutex; initialised to N, it is a pool of N. The idea is Edsger Dijkstra's, set out in his 1965 note "Cooperating Sequential Processes".

Fig 4.18 · Two processes, one region, one gate
Fig 4.18 · Two processes, one region, one gate Process A maps physical page 0x4000 at its virtual address 0x7f2a1000; process B maps the same page at 0x55e0c000. Between them and the page stands a semaphore holding 1. A calls sem_wait, takes the 1 (the value drops to 0) and writes to the page. B calls sem_wait, finds 0 and sleeps. A calls sem_post, the value returns to 1, B wakes, takes it and writes. When B calls sem_post the gate is open again. The six steps are listed under the picture, the current one lit. process A virtual 0x7f2a1000 process B virtual 0x55e0c000 semaphore 1 0 shared page, physical 0x4000 A B 1. A takes the 1; the gate shows 0 1. A takes the 1; the gate shows 0 2. A writes the shared page 2. A writes the shared page 3. B finds 0 and sleeps 3. B finds 0 and sleeps 4. A posts the 1 back; B wakes, takes it 4. A posts the 1 back; B wakes, takes it 5. B writes the shared page 5. B writes the shared page 6. B posts the 1 back; the gate is open 6. B posts the 1 back; the gate is open

Both processes map the same physical page at different virtual addresses, so what one stores the other can load, with no copy and no system call. A semaphore holding 1 lets one in at a time: A takes the 1, B finds 0 and sleeps, and A's sem_post() wakes it.

How it works
  1. The mapping. shm_open() and mmap() put the same physical pages into both address spaces; after that the region is ordinary memory.
  2. The fast path. On Linux, sem_wait() and sem_post() use atomic instructions on a counter in shared memory; the kernel is called only when someone must sleep or be woken.
  3. Counting. A semaphore started at N admits N at once, a pool rather than a lock. The idea is Dijkstra's, set out in 1965.

Sockets: when the other process is on another machine

A socket is a generalisation of the pipe: a connection either to another process on the same machine (a "UNIX domain socket") or to a process on a different machine entirely (a "network socket," using TCP or UDP). The same system calls apply: read, write, and close. The network drivers, the networking code in the kernel and the wire itself are all hidden beneath those few calls.

That is why a web browser, a database client, and a remote login (SSH) all use roughly the same code shape: open a socket, write a request, read a reply, close. Part III opens up what happens between write and read: ARPANET, packet switching, TCP, DNS, TLS, HTTP.

06 · Security

When the kernel breaks

Everything we've built in this chapter (the privilege boundary, the address-space isolation, the file permission checks, the IPC mediation) rests on one assumption: the kernel is correct. A bug in the kernel is therefore not an ordinary bug. A user-space program with a buffer overflow can be exploited to take over that program. A kernel with the same kind of bug can be exploited to take over everything: the kernel itself, every other process, every file, every keystroke.

Privilege escalation, more carefully

A privilege escalation attack is one where a process gains more privilege than it was supposed to have. The two important versions:

Vertical escalation: moving up the privilege ladder. A normal user process exploits a bug to become root (the administrator account), or root, on a system that walls even root off from the kernel (Secure Boot's lockdown mode, say), exploits a bug to enter Ring 0 (kernel mode).

Horizontal escalation: moving sideways at the same privilege level into another process's data. Reading another user's files, hijacking another user's session.

Kernel privilege-escalation bugs of the last decade include Dirty COW (the copy-on-write race condition mentioned earlier, 2016), DirtyPipe (a flaw in pipe-buffer initialisation that allowed writes to read-only files, 2022), and dozens of bugs in device drivers. Driver code is vast, often less audited than the core kernel, and runs in Ring 0. Most modern kernel exploits chain together multiple bugs: an information leak to find where the kernel sits in memory, defeating KASLR (the kernel's version of ASLR), then a memory-corruption bug to overwrite whatever grants privilege.

Containers: not virtual machines

Containers, the units Docker and Kubernetes run, are often described as "lightweight VMs," which is misleading. A virtual machine imitates an entire computer, kernel and all, on a hypervisor, a thin layer of software that lets one machine run several. A container is a normal Linux process that runs on the one shared kernel: a process the kernel has lied to, using two Linux features:

Namespaces partition kernel resources so that different processes see different views. There are namespaces for process IDs (a containerised process believes it is PID 1, even though to the host it might be PID 8472), for the network (the container has its own virtual network interface), for mounts (its own tree of files from / down, unpacked from a container image, not the host's), for user IDs, for hostnames, and more. Inside its namespaces, the container looks like a complete isolated system.

Cgroups (control groups) limit how much of each resource a process group can use: CPU time, memory, disk I/O, the number of processes. Together with namespaces, this gives Docker its model: cheap, fast isolation that doesn't require a hypervisor.

Fig 4.19 · VMs vs containers
Fig 4.19 · VMs vs containers Left, virtual machines: on the hardware runs a hypervisor; on it, each virtual machine runs its own guest kernel with its app above it, so each app's system calls go to its own kernel. Right, containers: on the hardware runs one host kernel; three apps sit on it, each fenced by namespaces and cgroups, and all of their system calls go to that one shared kernel. Virtual machines cost a kernel and gigabytes each and boot in seconds; containers share one kernel, cost megabytes and start like a process. VIRTUAL MACHINES app kernel app kernel hypervisor CONTAINERS app app app one host kernel hardware hardware a kernel per VM gigabytes each boots in seconds one kernel, fenced megabytes each starts like a process

A virtual machine runs its own guest kernel on a hypervisor; a container is an ordinary process whose system calls go to the one host kernel, fenced by namespaces (what it can see) and cgroups (what it can use).

How it works
  1. Hypervisors. KVM turns Linux into one; VMware ESXi, Xen and Hyper-V are others. CPU extensions (Intel VT-x, AMD-V) let guest kernels run directly on the processor.
  2. The fences. Namespaces and cgroups are ordinary kernel features; the next two figures show each.
  3. The middle ground. MicroVMs such as AWS Firecracker boot a minimal guest kernel in about 125 ms, giving each container its own kernel at nearly container cost.

Each VM has its own guest kernel, so a kernel bug exposes only that VM unless the hypervisor has a flaw too; containers share one, so a kernel privilege-escalation bug can let a malicious container take over the host and every container beside it. Cloud providers that run strangers' code side by side therefore use VMs, or containers each wrapped in a minimal VM (AWS's Firecracker); machines that run one owner's code use plain containers, which are far cheaper.

Fig 4.20 · Namespaces: each container sees its own world
Fig 4.20 · Namespaces: each container sees its own world Five questions asked from three places on one machine. From container A: PID 1 is redis, redis is PID 1, the hostname is redis-host, the network address is 10.0.0.2 and the root filesystem is the redis image. From container B: PID 1 is nginx, redis is not visible at all, the hostname is web-host, the network address is 10.0.0.3 and the root is the nginx image. From the host: PID 1 is systemd, redis is process 8472, the hostname is prod-01, the network address is 192.168.1.10 and the root is the host disk. One kernel gives all three answers. container A container A container B container B host host PID 1 is redis (database) nginx (web server) systemd (starts all) redis is PID 1 not visible 8472 hostname redis-host web-host prod-01 network address 10.0.0.2 10.0.0.3 192.168.1.10 root filesystem redis image nginx image host disk one kernel answers all three

Ask the same five questions from inside container A, inside B and on the host, and one kernel gives three sets of answers: each container sees its own PID 1, hostname, network and root filesystem. A PID namespace is a translation table, not a copy: to the host, A's PID 1 is just process 8472.

How it works
  1. Eight kinds. Mount, UTS (hostname), IPC, PID, network, user, cgroup and, since Linux 5.6, time. Each can be set separately.
  2. Making one. clone() with a flag such as CLONE_NEWPID starts a new process in a fresh namespace; unshare() moves the caller itself into one (for PID, only its next children). Children inherit the namespace.
  3. The network. A container's network namespace gets its own virtual network card, joined by a virtual cable to the host, which passes its traffic on. Part III opens up networking.
Fig 4.21 · Cgroups: quotas as taps on the resource flow
Fig 4.21 · Cgroups: quotas as taps on the resource flow A host with 16 CPUs and 64 GB of memory runs three cgroups. web is limited to 4 CPUs and 16 GB, db to 8 CPUs and 32 GB, batch to 4 CPUs and 16 GB. web and db use part of their share. The batch job asks for 12 CPUs; the kernel throttles it at its 4. Its memory then grows to its 16 GB limit and the kernel kills it; web and db carry on untouched. CPU · host has 16 · limit outlined MEMORY · host has 64 GB web 4 web 16 db 8 db 32 batch 4 batch 16 wants 12 killed batch asks for more CPU wants 12 CPUs, gets 4: throttled batch memory grows toward 16 GB over its 16 GB: killed batch killed; web and db never noticed

Namespaces decide what a process can see; cgroups decide what it can use, with a CPU and a memory limit per group that the kernel enforces. A runaway batch job wanting 12 CPUs is held to its 4, and when it outgrows its 16 GB the kernel kills it, while web and db never notice.

How it works
  1. The interface. Cgroup v2 is a filesystem at /sys/fs/cgroup. Writing 400000 100000 to cpu.max allows 4 CPUs' worth of time in every 100 ms period.
  2. Memory. Past memory.max the kernel first reclaims the group's own pages; if that fails, the OOM killer picks a process inside the group.
  3. Who writes them. Docker's --cpus and --memory, Kubernetes resource limits and systemd's services all write these files; io.max and pids.max are further files, for disk and process counts.

eBPF: safe code inside the kernel

A more recent Linux feature is eBPF, "extended Berkeley Packet Filter." It allows user programs to upload small pieces of code into the running kernel, where they are attached to specific events (network packets, system calls, function entries) and run inside Ring 0.

The obvious worry is that this lets user code into the kernel, historically the worst possible security outcome. eBPF answers with a verifier: a checker in the kernel that reads every uploaded program and refuses to load it unless it can prove the program will always terminate, never read out-of-bounds memory, and never crash. Turing showed in Chapter 1 that no checker can decide whether every program ends, so the verifier settles for less: it accepts only programs simple enough to prove, such as loops with a known bound, and refuses everything else, even some programs that would have ended. Programs that pass are then translated into the CPU's own machine code and run essentially as fast as compiled kernel code. (Belt and braces: modern kernels also restrict who may load programs at all.)

Fig 4.22 · eBPF: untrusted code in the kernel, made safe by proof
Fig 4.22 · eBPF: untrusted code in the kernel, made safe by proof Two eBPF programs are sent from user space to the kernel (with the bpf() system call). The kernel's verifier checks each one. The first, which counts packets, is proven to end and to stay within its memory bounds; it is compiled to native code and attached to the packet-arrival hook, where it runs once per packet. The second, an endless while loop, cannot be proven to end; the verifier refuses it with an error and it never runs in the kernel. USER SPACE program A count packets program B loop forever sent to the kernel KERNEL verifier proves: it ends, stays in bounds A: ends, in bounds: accept B: no proof it ends: refuse to machine code attached refused with error never runs packet in A ran 0× A ran 1× A ran 2× A ran 3× A ran 4×

Program A, a packet counter, is proven to end and stay in bounds, so it is compiled to native code and runs on every packet; program B, an endless loop, is refused and never runs.

How it works
  1. The toolchain. clang compiles restricted C to eBPF bytecode, and the bpf() system call hands it to the kernel.
  2. The proof. The verifier walks every path through the program, tracking the range of every register. Loops pass only when it can bound them (allowed since Linux 5.3), and its effort is capped.

eBPF now underlies much of Linux's tracing (bpftrace), networking (Cilium) and security monitoring (Falco). The same pattern recurs elsewhere: untrusted code runs inside a privileged place under constraints the machine enforces. eBPF proves the code harmless before it runs; the browser of Chapter 12 fences a web page's JavaScript in while it runs.

The question every layer answers. What is less-trusted code allowed to do here, and what stops it? Every mechanism in this chapter (the kernel itself, the privilege boundary, virtual memory, filesystem permissions, container isolation, eBPF verification) is a different answer to that one question, and each restricts rather than trusts. Any one of them can have a bug, so the walls are layered: a container that takes over its kernel inside a Firecracker VM still has the VM's walls around it.

What you now understand

The kernel is a program that owns the hardware and mediates everything every other program does. Written in C and loaded by a bootloader, it stays resident in Ring 0. It comes in two main shapes: monolithic (everything in Ring 0, fast, fragile) and microkernel (minimal Ring 0, message-passing, safer and slower), with most real systems sitting somewhere on that spectrum. Its scheduler, anchored mathematically in queueing theory, decides which process gets the CPU next, hundreds of times a second. Linux's CFS, and EEVDF after it, does this in O(log N) steps using a red-black tree of virtual runtimes. Its virtual memory subsystem maintains a four-level page table per process, accelerated by a translation cache (TLB), and uses page faults productively to implement memory-mapped files and copy-on-write fork. Its filesystem turns a flat array of disk blocks into a tree of inodes and names, made crash-safe by journaling or copy-on-write. Its IPC mechanisms (pipes, signals, shared memory, sockets) are the controlled boundary across which isolated processes can still cooperate. And its security depends entirely on its own correctness, which is why kernel bugs are so dangerous, and why each new mechanism (containers, eBPF) is layered behind further verification.

With this, the kernel is in view from both sides. The Bridge at the close of Part I showed it as silicon: privilege rings as a CPU bit, the trap as one instruction into an entry point the kernel chose, the MMU as a hardware unit, the timer as the one circuit that makes preemption possible, MMIO and DMA as the kernel's line to the devices. This chapter showed it as code. Same object, two different perspectives, and the kernel becomes legible only when both are visible at once: thirty million lines of C running in Ring 0, sustained by a hardware contract that lets it own the machine.

The next chapter climbs out of the kernel and into the language it was written in. C (Ritchie at Bell Labs, 1972) is still the language of Linux and most production kernels. Pointers turn out to be the addresses we just walked through: the source of C's power, and of every memory bug we met in Chapter 3.

Chapter 05

The Language
That Built
Everything

Before C, an operating system was usually written in assembly for one machine, then rewritten for the next. C broke the pattern by being portable: one source, many architectures. UNIX itself was rewritten in it. Half a century later, the kernel you are using now, the browser rendering this page and the language runtime executing it are all C, or built on something that is.

TopicsPortability · Pointers · Undefined behaviour · The systems contract
Era covered1969 → present
Listinglines 0791–1608 · cc unix.c
Chapter 05 hero · The Language That Built Everything one C file compiled for each CPU x86-64 most PCs ARM64 phones, Macs RISC-V open design PDP-11 where C began
01 · The portability problem

One source. A thousand binaries.

Until 1973, an operating system was usually written in assembly for one specific machine. To run it on a different computer, you rewrote it, instruction by instruction, for that computer's CPU. Then C arrived, and one source file became a thousand binaries.

In 1965, IBM shipped the System/360, a family of computers whose systems software was written in their own assembly language. The PDP-11, a minicomputer that the Digital Equipment Corporation (DEC) shipped in 1970, had a different instruction set: bits that meant one instruction on the System/360 meant another on the PDP-11. A System/360 program could not be adjusted to run there; it had to be written again.

This was the normal state of computing: operating systems, compilers, every piece of systems software, all rewritten per machine. A small army of programmers spent their careers translating known-good programs into yet another assembly. When a new CPU appeared, porting cost so much that most software simply did not make the trip.

Fig 5.1 · One C source, many targets
Fig 5.1 · One C source, many targets The C function int add(int a, int b) { return a + b; } compiled for three processors, each holding a and b in its own registers. x86-64, with a in rdi and b in rsi: lea eax,[rdi+rsi], which adds them, then ret, encoded as the 3 bytes 8D 04 37 and the 1 byte C3. ARM64, with a in w0 and b in w1: add w0,w0,w1 then ret, two fixed 4-byte words, 0B010000 and D65F03C0. 64-bit RISC-V, with a in a0 and b in a1: addw a0,a0,a1 then ret, two 4-byte words, 00B5053B and 00008067. Different instructions, registers and encodings; identical behaviour. A key below contrasts x86's variable lengths with the 4-byte words of ARM and RISC-V (RISC-V also has 2-byte compressed forms), and explains base 16 and the bracket sum. C source int add(int a, int b) { return a + b; } machine code x86-64 lea adds: a in rdi, b in rsi lea eax,[rdi+rsi] 8D 04 37 3 bytes ret C3 1 byte ARM64 a in w0, b in w1 add w0,w0,w1 0B010000 4 bytes ret D65F03C0 4 bytes RISC-V 64 addw adds: a in a0, b in a1 addw a0,a0,a1 00B5053B 4 bytes ret 00008067 4 bytes x86: 1 to 15 bytes · ARM64: 4 · RISC-V: 4 or 2 bytes written in base 16: digits 0–9, A–F [rdi+rsi]: the sum of rdi and rsi

One C function compiled for three processors: different instructions, different registers (eax, w0, a0) and different encodings, x86's variable-length bytes against the 4-byte words of ARM and RISC-V. The behaviour is identical, which is what portable means for C: the same source, compiled separately for each processor.

How it works
  1. Calling conventions. Each platform's ABI says where arguments arrive (rdi/rsi, w0/w1, a0/a1) and where the result goes back.
  2. The lea trick. x86 compilers borrow lea, an address-arithmetic instruction, to add two registers into a third in one step. The brackets look like Fig 3.2's memory, but lea only works out the address and never reads it, so here the address is the sum.
  3. The w in addw. On 64-bit RISC-V it adds 32-bit ints. With the compressed extension both instructions shrink to 2 bytes each.

What makes a language portable

A portable language needs three things. It must hide the differences Fig 5.1 shows (instructions, registers, encodings) without giving up speed. It must be small enough that a few people can write its compiler for a new processor. And it needs one picture of memory that fits every machine, though processors differ in how many bits make up a word.

C meets all three by building only on what processors have in common and leaving the rest to the compiler. Its types are the byte and an integer of the machine's natural size, its if statements and loops become conditional jumps, and its data structures are byte layouts you could draw on graph paper. Translating that is simple: a competent compiler-writer can target a new ISA in a few months, and C written for the PDP-11 half a century ago still compiles, with small changes, on a 2026 ARM Mac. The price falls on the programmer, who handles the machine's memory directly.

BCPL → B → C

BCPL (Martin Richards, Cambridge, 1967) was typeless: every value was a machine word, whether it held a number, a letter or an address. B (Ken Thompson at Bell Labs, 1969), a smaller BCPL, was just as typeless and ran on early UNIX. But the PDP-11 that arrived at Bell Labs in 1970 was byte-addressable, every byte with its own address, and B, which saw only words, had no direct way to reach one. By 1972 Ritchie had added types, so the compiler knew a char (one byte) from an int (a word), and he called the result C: the language that comes after B.

C solved portability by being just barely abstract enough: close enough to assembly to compile efficiently on any reasonable CPU, just far enough above it never to mention specific registers.

02 · Ritchie and Thompson at Bell Labs

Two men, a spare minicomputer, four years.

At Bell Labs in 1969, Ken Thompson found a little-used PDP-7 and started writing a programming environment for himself. His friend Dennis Ritchie joined. Within four years they had built UNIX, written the C language to write more UNIX in, and laid the foundations of most operating systems you use today.

Bell Labs had been one of three institutions building Multics, a vast time-sharing operating system meant to be a "computing utility" for hundreds of simultaneous users. Multics was technically remarkable and managerially catastrophic. By 1969 Bell Labs had pulled out. Thompson and Ritchie, who had been Multics contributors, still wanted that kind of system but had no machine to build it on and no approved project to build it in.

Thompson took over a little-used PDP-7, an 18-bit minicomputer already old by 1969; DEC had stopped selling it. He and Ritchie first rewrote a space-travel game of his, written on Multics, to run on it. Then Thompson began a small operating system there, to have a better environment to work in. By the end of 1969 there was a kernel, a shell, an editor, and a primitive filesystem. Brian Kernighan, noting that the system was a stripped-down Multics, quipped that they should call it "UNICS" (UNiplexed Information and Computing Service), a pun on Multics, the MULTiplexed Information and Computing Service. The name became UNIX.

Fig 5.2 · UNIX's rewrite, in time
Fig 5.2 · UNIX's rewrite, in time A timeline from 1969 to 1978, coloured by the language UNIX itself was written in. 1969: UNIX on a PDP-7, in assembly; Thompson's typeless language B. 1970: the byte-addressed PDP-11 arrives. 1971: the First Edition, in PDP-11 assembly. 1972: C, which is B plus types. 1973: UNIX is rewritten in C, and the colour changes from assembly to C. 1977: the C source of UNIX is ported to the Interdata 8/32. 1978: K&R, Kernighan and Ritchie's book on C. UNIX source in: assembly C 1969 UNIX on a PDP-7, in assembly 1969 Thompson's language B: typeless 1970 PDP-11 arrives: byte-addressed 1971 First Edition: PDP-11 assembly 1972 C: language B plus types 1973 UNIX rewritten in C 1977 in C, UNIX moves to Interdata 8/32 1978 K&R: Kernighan & Ritchie's C book

UNIX began in 1969 as PDP-7 assembly and moved to the PDP-11 still in assembly; in 1973, with C ready, Ritchie and Thompson rewrote the kernel in it. Four years later it ran on an Interdata 8/32, a machine of a different design, carried over largely by recompiling.

How it works
  1. The rewrite. The 1973 Fourth Edition was the first UNIX with its kernel mostly in C; a small machine-dependent part stayed in assembly.

The PDP-11

The PDP-11 was a 16-bit minicomputer: small (the size of a refrigerator, not a room), with eight general-purpose registers, byte-addressable memory and a clean, regular instruction set. C was shaped around it. A C pointer, a variable that holds an address (Section 03 explains them), steps by the size of what it points at: on the PDP-11, 1 byte for a character and 2 for an integer (Fig 5.3). To a first approximation, C is a portable PDP-11 assembler.

Fig 5.3 · PDP-7 to PDP-11 · the machines that shaped C
Fig 5.3 · PDP-7 to PDP-11 · the machines that shaped C The string UNIX in the memory of two machines. On the PDP-7 of 1964, memory is 18-bit words and an address names a whole word: "UN" sits in word 100 and "IX" in word 101, and a pointer steps a word at a time. On the PDP-11 of 1970, words are 16 bits but every byte has its own address: U, N, I and X sit at 1000 to 1003. A char pointer c steps by 1 byte (c + 1 adds 1 to the address), an int pointer i by 2 (i + 1 adds 2). Pointer arithmetic that depends on what is pointed at is why C gave its pointers types. PDP-7 · 1964 18-bit words; an address names a word U N 100 I X 101 pointer p PDP-11 · 1970 16-bit words; every byte has an address U 1000 N 1001 I 1002 X 1003 char pointer c int pointer i c + 1: next byte, address + 1 i + 1: next word, address + 2

On the PDP-7 an address named a whole 18-bit word, two characters at a time; on the PDP-11 every byte had its own address. A pointer to a character must then step by 1 and a pointer to an integer by 2, which is why C, unlike B, gave its pointers types.

How it works
  1. The PDP-7. Bell Labs' machine had 8K words of 18 bits. B, its language, had a single type, the word; reaching one character meant extra instructions to pull it out of its word.
  2. The PDP-11. A 16-bit design with byte addresses, eight registers and regular addressing modes.
  3. The legacy. Flat byte-addressed memory and pointers that scale by the size of what they point at are C's model, and nearly every processor since provides it.

K&R, 1978

Brian Kernighan and Dennis Ritchie published The C Programming Language with Prentice-Hall in 1978: 228 pages, every example a few lines you could compile and run, starting on page 6 with a program that prints hello, world. K&R, as the book is known, defined the language until ANSI standardised it in 1989. It is short and exact, and it assumes an intelligent reader: someone could plausibly read it in an afternoon and write working code that night.

"C is quirky, flawed, and an enormous success."

Dennis Ritchie
03 · Pointers as math

A pointer is an integer with attitude.

A pointer is an integer: the address of a byte in memory. What sets C apart from most languages since is that it lets you do arithmetic on these integers directly, and never checks that the result still points at anything valid.

To a C program, memory is a giant array of bytes, and each byte has an integer address. A pointer in C is one of those addresses, stored in a variable that the compiler has labelled with the type of thing the address is supposed to point at.

Three operators do nearly all the work. &x is the address where the variable x lives. *p dereferences the pointer p, reading what is stored at its address: if p holds the address of an int, *p is that int. And p + 1 means the next thing of the type p points to, not the next byte: if p is an int * (a pointer to int) and an int takes four bytes, p + 1 is four bytes further on. The compiler scales the arithmetic for you.

Fig 5.4 · A pointer walks down memory
Fig 5.4 · A pointer walks down memory Thirty-two bytes of memory from address 0x100, holding eight 4-byte ints: 17, 42, 99, −7, 23, 8, 61, 12. Three pointers start at 0x100 and each is incremented together. The char pointer moves one byte per step, the int pointer four, the double pointer eight. Each pointer's block is as wide as what it reads, and a label gives its step: 1, 4 or 8 bytes. The double pointer stops at 0x118, the last double that fits in the 32 bytes, while the others finish. 17 0x100 42 99 0x108 −7 23 0x110 8 61 0x118 12 * marks a pointer char *c moves 1 byte = 0x100 = 0x101 = 0x102 = 0x103 = 0x104 = 0x105 = 0x106 = 0x107 int *p moves 4 bytes = 0x100 = 0x104 = 0x108 = 0x10C = 0x110 = 0x114 = 0x118 = 0x11C double *d moves 8 bytes = 0x100 = 0x108 = 0x110 = 0x118 (last double) each ++ (+ 1) moves one element

Three pointers start at 0x100 and take the same step, ++: the char pointer moves 1 byte, the int pointer 4, the double pointer 8, until the next double would leave the 32 bytes shown.

How it works
  1. The sizes. A char is 1 byte by definition; int is 4 and double 8 on today's common machines, though the standard only sets minimums.
  2. What *p reads. The block in each pointer's track is as wide as the span a dereference loads: one, four or eight bytes from its address.
  3. Where it leads. Strings are char pointers walking to a zero byte, and arrays are the same arithmetic in disguise, as the next figure shows.

Arrays are pointers in disguise

The C standard defines a[i], element i of the array a (counting from 0, so a[0] is the first), to mean exactly *(a + i): indexing is shorthand for pointer arithmetic. It works in both directions: with a pointer p, p[3] means *(p + 3), and with an array a, *a is a[0].

Fig 5.5 · a[i] ≡ *(a + i)
Fig 5.5 · a[i] ≡ *(a + i) An int array a at address 0x100 holding 17, 42, 99, −7, 23, 8. The expression a[2], element 2 of a, is rewritten step by step: by definition it is *(a + 2); the addition is scaled by the size of an int, 4 bytes, so it is *(0x100 + 2 × 4), which is *(0x108), and the star reads the value there, 99. Then 2[a] is rewritten the same way: *(2 + a), and since addition commutes, *(a + 2), which is again 99. 17 0x100 42 0x104 99 0x108 −7 0x10C 23 0x110 8 0x114 int a[6] a[2]element 2 of a = *(a + 2)the definition = *(0x100 + 2 × 4)an int is 4 bytes = *(0x108)* reads what is at 0x108 = 99 2[a]a and 2 swapped = *(2 + a)same definition = *(a + 2)addition commutes = 99legal, never written

a[2] rewritten step by step down to the 99 stored at 0x108. 2[a], legal but never written, reads the same 99, because addition commutes.

How it works
  1. The rule. C99 §6.5.2.1: "E1[E2] is identical to (*((E1)+(E2)))."
  2. The decay. In most expressions an array's name turns into a pointer to its first element, which is why a + 2 means an address at all.
  3. The cost. Nothing in *(a + i) knows how long the array is, so a[10] on a six-element array is undefined behaviour (Section 05); in practice it reads whatever lies beyond it.

What this lets you do

Pointers let a program join data that sits anywhere in memory: a list can be a chain of items, each stored with the address of the next. A pointer can also hold the address of code, so a program can choose while it runs which function to call: a sorting routine can be handed the function that compares two items, and the virtual method tables (vtables) of Chapter 6 are tables of such pointers. And because a device's registers sit at memory addresses (the memory-mapped I/O of the Bridge), a pointer set to one of them lets a driver written in C command the hardware directly.

What this lets you screw up

A pointer that points outside its valid range will write anywhere your process is allowed to write, and nothing checks. If arr holds ints and i is too large, arr[i] = x stores x into whatever lies 4×i bytes past the start of arr. If that is the saved return address on the stack, you have the buffer overflow of Chapter 3.

The trade: Pointer arithmetic gives C direct access to memory with nothing checking it, and that missing check is where C's memory-corruption vulnerabilities come from.

04 · Manual memory management

You allocated it. You free it.

Every byte of heap memory your C program uses, you allocated. Every byte you allocated, you must free. Forget to free, and your program leaks until the OS kills it. Free twice, or use a freed pointer, and your program corrupts itself in ways that may not show up for hours. C makes you the bookkeeper: nothing in the language frees memory for you.

Recall from Chapter 3 that memory comes in two main flavours: the stack, which grows and shrinks automatically as functions enter and exit, and the heap, a region the kernel hands the process for long-lived allocations. Heap memory requires explicit requests. C exposes those requests through two functions: malloc(n) asks for n bytes of heap and returns the address; free(p) hands the bytes at address p back for reuse.

Behind these two functions lives an entire algorithm, the allocator. The kernel hands the process one big chunk of address space, and the allocator cuts it into pieces as malloc calls ask for them and joins neighbouring free pieces back together as free calls return them. Over time the heap becomes a patchwork of chunks, each marked in use or free (a chunk in use never moves, because the program holds its address), with the free ones linked into the allocator's free list, which it searches for room.

Fig 5.6 · The heap, as a free list
Fig 5.6 · The heap, as a free list A heap of 560 bytes. Five allocations take 64, 128, 32, 96 and 64 bytes, leaving 176 free at the end. p2 and p4 are freed. The free list now links three chunks of 128, 96 and 176 bytes, 400 bytes in all, each separated from the next by memory still in use. A request for 300 bytes is checked against each free chunk in turn and fits none of them, so the allocator must ask the kernel for more. HEAP · 560 bytes p1 64 p2 128 free p3 32 p4 96 free p5 64 176 free free list malloc(300) five mallocs free(p2), free(p4) 400 bytes free, in three pieces checking each free chunk none fits: ask the kernel for more largest free piece: 176 B

After five allocations and two frees the heap holds 400 free bytes in three pieces, and a request for 300 fits none of them. The heap is fragmented: free memory in pieces too small to use, so the allocator must ask the kernel for more.

How it works
  1. The list. Each free chunk stores, inside its own unused bytes, a pointer to the next free chunk; the allocator walks that list to find room.
  2. Coalescing. Freeing a chunk next to free space merges the two. Here every free piece is fenced by a chunk still in use, so nothing can merge.
  3. Strategies. First fit, best fit and size-class bins (glibc's ptmalloc, jemalloc) trade speed against fragmentation.

The two failure modes

Mistakes with malloc and free come in two kinds. The first is to allocate and never free: a leak. The chunk stays in use forever, even though no pointer in the program references it any more. Run a leaky program long enough and the OS kills it for exhausting memory. The second is the inverse: free a chunk and keep using the pointer to it, if only to free it again. This is a use-after-free. The chunk has gone back to the free list and may have been handed to a different malloc caller; reading it now reads someone else's data, writing it corrupts that data.

Fig 5.7 · Leak vs use-after-free
Fig 5.7 · Leak vs use-after-free Two ways to get malloc and free wrong. A leak: three times, the pointer p is given a chunk and then goes out of scope without free; each chunk stays marked in use with nothing pointing at it, and the heap only grows. A use-after-free: p is given a chunk, free(p) hands it back to the allocator while p still points at it, the next malloc gives the same chunk to q, and a write through p overwrites q's data. leak: allocated, never freed p free in use in use, no pointer free in use in use, no pointer free in use in use, no pointer returns, no free the heap only grows use-after-free: freed, still used p q free p's data free q's data q's data, overwritten p = malloc(64) free(p): p still points here q = malloc(64): the same chunk write through p: q is hit

In the leak, three chunks lose their only pointer and the heap only grows. In the use-after-free, p outlives free(p), the same chunk goes to q, and a write through p lands in q's data.

How it works
  1. Why the same chunk. Allocators reuse recently freed chunks of the right size first, because they are likely still in the cache, so the next malloc(64) often gets exactly the chunk just freed.
  2. Finding them. Valgrind and AddressSanitizer track every allocation: they report leaks when the program exits and a use-after-free when it happens, as long as the freed chunk has not yet been reused.
  3. Defences. Setting p = NULL after free(p) turns a later use into an immediate crash; hardened allocators delay reuse; in safe Rust, the compiler gives every value one owner, which frees it, and rejects any use after that at compile time.

Why this is still in production

Garbage collection, invented by John McCarthy for Lisp in 1959, frees memory automatically: code called a collector finds the chunks no pointer reaches any more and frees them, often pausing the program while it walks the heap. By 1990 the technique was well understood, and C still did without it. One reason that still holds is predictability. Sound hardware plays 48,000 samples a second, each a number tracing the sound wave, from a buffer only a few milliseconds deep; a 100-millisecond collector pause leaves the buffer empty, and the sound breaks up. In C, memory is freed exactly when you free it, never sooner and never later.

05 · Undefined behaviour

The optimiser's cruel logic.

The C standard contains a list of operations whose behaviour is "undefined": the standard places no requirement at all on what happens, not even a crash. Modern optimisers, the parts of a compiler that rewrite code to run faster, exploit that freedom, and code that worked for ten years can break catastrophically on the next compiler upgrade.

The core of the list is short and dangerous: signed integer overflow; dereferencing a null pointer; reading or writing past the end of an array; reading a variable before anything has been stored in it. Each is a contract the programmer has implicitly accepted: "I promise I will never do this." The compiler follows C's old motto, "trust the programmer": it never checks whether you can keep the promise, and generates code that only makes sense if you did.

Adding 1 to the largest int makes the processor wrap round to the most negative one (Fig 2.8), so a careful programmer guards with if (i + 1 < i) abort();, which stops the program if the sum has wrapped. But the wrap is only what the hardware does; the C language promises nothing about it. Overflow is on the list above, so the optimiser assumes it never happens and reasons from there:

Fig 5.8 · How "trust the programmer" deletes your bug check
Fig 5.8 · How "trust the programmer" deletes your bug check A function int next(int i) checks if (i + 1 < i) abort(); and then returns i + 1. The compiler reasons: signed overflow is undefined behaviour, so it never happens; so i + 1 is greater than i for every i; so the test is always false; so the call to abort is dead code. The check is deleted, and gcc at -O2 emits only lea eax,[rdi+1], which computes i + 1 from i in register rdi, and ret. int next(int i) {   if (i + 1 < i) abort();   return i + 1; } the compiler, at -O2: 1overflow is UB: it never happens 2so i + 1 > i for every i 3so (i + 1 < i) is always false 4so abort() is dead code lea eax,[rdi+1]reti + 1 (i is in rdi)no check leftlea: works out the sum in [ ]

Signed overflow is undefined behaviour (UB in the figure), so the compiler may assume it never happens; from that, i + 1 < i is always false and the check is dead code, code that can never run. With optimisation switched on (-O2), the gcc compiler deletes it, leaving only an add and a return.

How it works
  1. The safe test. Check before adding, if (i == INT_MAX), or use __builtin_add_overflow in gcc and clang.
  2. The kernel's answer. Linux builds with -fno-strict-overflow and -fno-delete-null-pointer-checks, switching these deductions off.
  3. Not malice. The standard lets the optimiser assume undefined behaviour never occurs; the same freedom is what lets it delete code that truly is dead.

NULL dereference

A pointer with the value 0 (or NULL, C's name for it) points nowhere: the kernel leaves the lowest pages of every process unmapped, so touching one takes the "invalid" exit of Fig 4.9 and ends in SIGSEGV, the Segmentation fault (core dumped) message that has greeted C programmers for fifty years. That crash is the operating system's doing, not a promise of C. The dereference is undefined, so an optimiser that sees *p may assume p is not NULL and delete a later if (p == NULL) check, as Fig 5.8 deleted the overflow test; that is why Linux builds with -fno-delete-null-pointer-checks.

Fig 5.9 · A NULL dereference, traced
Fig 5.9 · A NULL dereference, traced User code writes the letter a through a NULL pointer: *p = 'a' with p equal to NULL, address 0. The MMU finds no page mapping address 0 and raises a page fault. The kernel's fault handler finds that address 0 lies in no region the process owns, so the access is illegal, and it sends the process SIGSEGV, signal number 11. The default action for that signal terminates the process and dumps core, and the shell prints Segmentation fault (core dumped). user code write 'a' where p points *p = 'a'; p is NULL, address 0 MMU no page maps address 0: fault kernel 0 is in none of the process's regions signal SIGSEGV, number 11 default action terminate, dump core shell: Segmentation fault (core dumped)

One write through NULL, followed from user code through the MMU, the kernel and the signal to the shell's message.

How it works
  1. Why address 0 faults. Operating systems leave the lowest pages unmapped on purpose (Linux's mmap_min_addr), so a NULL access that reaches the processor traps instead of reading data.
  2. The same path. This is the "invalid" exit of the page-fault tree in Fig 4.9; only the outcome differs from a minor fault.
  3. The core file. "Core dumped" appears only when the system allows it; the dump is a snapshot of memory for a debugger to open.

The optimiser's contract, the programmer's homework

Careful C projects test under UndefinedBehaviorSanitizer, which compiles in checks for many of these promises, signed overflow among them, and reports each one the running program breaks.

The right way to think about undefined behaviour: once a program breaks one of these promises, its behaviour is no longer governed by the source you wrote, and the optimiser is entitled to act on that.

06 · Why C survives

Half a century later, still in the foundation.

Every five years, someone writes a clickbait piece announcing that C is dying, and every five years more C runs in production than ever before. Linux is written in C, macOS's XNU kernel and Windows NT mostly so, and the browser rendering this page in C++, C's best-known descendant. Fig 5.10 follows one Python program down through the C beneath it.

Three properties keep C at the bottom of every system. Performance: C compiles straight to machine code, so a program that keeps C's promises runs very nearly as written. Predictability: nothing runs that the program did not ask for, with no garbage collector pausing it on its own schedule and no just-in-time compiler recompiling its busiest code while it runs. Ubiquity: a new processor gets a C compiler before any other language, embedded systems speak C, and kernels describe their interfaces in C.

Fig 5.10 · Where C still runs in 2026
Fig 5.10 · Where C still runs in 2026 A request from a Python program, app.py, travels down the layers beneath it: the CPython interpreter, libraries such as OpenSSL and SQLite, libc, the Linux kernel, and the firmware of the disk and network controllers. The program is Python; every layer beneath it is written in C, the firmware mostly so. app.py your program Python CPython the interpreter running it C OpenSSL · SQLite libraries it calls C libc the system-call wrappers C Linux the kernel C firmware disk and network controllers mostly C

Underneath a Python program the work soon runs in C: its request passes through the CPython interpreter, OpenSSL and SQLite, libc and the Linux kernel, all written in C. C holds the layers where cycles and predictability matter most, and pays for it in memory-safety bugs.

How it works
  1. Why others lean on it. C's simple calling convention is the one every other language can call into.
  2. The price. Microsoft and Google's Chromium team have each reported that about 70% of their serious security bugs are memory-safety bugs.
  3. The successor. Rust has been accepted in the Linux kernel since 2022 and is entering Windows and Android, one layer at a time.
Fig 5.11 · The C family tree · half a century of descendants
Fig 5.11 · The C family tree · half a century of descendants C, from Bell Labs in 1972, at the root of three waves of descendants. Keeping C's speed and adding objects: Objective-C (1984) and C++ (1985). Keeping C's syntax on a managed runtime: Perl (1987), Java, JavaScript and PHP (all 1995), C# (2000) and Kotlin (2011). Keeping C's speed and adding safety: Go (2009), Rust (2010) and Swift (2014), which are memory-safe, and Zig (2016), which keeps manual control with fewer traps. A key says each box is a language and the year it appeared. C · 1972 · Bell Labs year 1984 year 1985 year 1987 year 1995 year 2000 year 2009 year 2010 year 2011 year 2014 to 2016 keep the speed, add objects Objective-C1984 C++1985 keep the syntax, add a managed runtime Perl1987 Java1995 JavaScript1995 PHP1995 C#2000 Kotlin2011 keep the speed, add safety Go2009 Rust2010 Swift2014 Zig2016 each box: a language and the year it appeared

Many major languages since 1972 grew out of C or were built as answers to it, in three waves: objects on top of C's speed, C's syntax on a managed runtime, and C's speed with more safety. The braces, semicolons and for loops of much of today's code are C's.

How it works
  1. First wave. An object bundles data with the functions that work on it (Chapter 6). Objective-C is a strict superset of C, and C++ nearly one: both compile C code almost unchanged.
  2. Second wave. Java, C# and Kotlin run on virtual machines with garbage collection; Perl, JavaScript and PHP borrowed C's look for scripting.
  3. Third wave. Rust rules out use-after-free and data races (two threads changing the same memory at once) at compile time, by giving every value one owner that the compiler tracks, and checks array bounds as the program runs; Go and Swift compile to machine code, as C does, but manage memory for you; Zig keeps manual control with fewer traps.

"There are only two kinds of languages: the ones people complain about and the ones nobody uses."

Bjarne Stroustrup

The seam to Chapter 6

Stroustrup, who said that, was reflecting on his own creation, C++. In 1979 he began work on spreading an operating system's services across a network, a job too large for plain C: it gave him speed and portability, but no tools to manage complexity at that scale. He set out to make C scale without giving up anything it did well.

Chapter 06

When C Needed
To Think In
Objects

By 1979, programs were outgrowing what one team could hold in its head, and C offered only the function to organise them with. Bjarne Stroustrup, in the same Bell Labs hallway as Ritchie and Thompson, asked: what if a language gave you all of C's speed but let you think in larger units than functions? His answer was C with classes added on top, in a way that costs you nothing if you don't use them. Renamed C++, it now runs trading systems, game engines, browsers and telephone switches.

TopicsEncapsulation · Vtables · RAII · Zero-cost abstraction
Era covered1979 → present
Listinglines 1609–2413 · c++ objects.cc
Chapter 06 hero · When C Needed To Think In Objects Shape ask any shape its area: its own version runs Circle its own area Square its own area Triangle its own area Hexagon its own area C++ objects, at C's speed is ais ais ais a
01 · The complexity problem

The crisis that wasn't a crash.

The 1980s software crisis was a slow, systemic realisation rather than a single failure. Codebases that had been small enough for one team to keep in their heads had grown too large for anyone to hold whole. Past that size, a change in one place could break something far away, and no one could tell whether the program was still right.

Frederick Brooks had already named the disease. The Mythical Man-Month, 1975, observed that adding people to a late software project makes it later: each newcomer must keep in step with everyone already there, so the cost of communication grows faster than the work. By the late 1970s, Bell Labs and IBM were running projects with hundreds of programmers and millions of lines of code, and watching them fail. IBM's OS/360, the very project Brooks was writing about, was the canonical case study; Multics, which Bell Labs had walked out of a decade earlier, was the one Bell Labs remembered.

C programs of the 1980s reached the same size and hit the same wall. For C, the diagnosis was that it gave the programmer one tool for organising code: the function. Functions could group statements; nothing but the source file grouped functions. There was no language-level concept of module, no class, no interface. A struct was a list of fields with no behaviour attached, and any function that knew its layout could reach in and change them. If your subsystem had thirty structs and three hundred functions, the language had nothing to say about which functions belonged to which struct. You enforced it by convention, in comments, in code review.

Fig 6.1 · The 1980s software crisis, in one chart
Fig 6.1 · The 1980s software crisis, in one chart Parts of a program drawn as points on a circle, with a line for every pair that could interact. Two parts have 1 possible interaction, four have 6, eight have 28 and sixteen have 120: n parts have n(n − 1)/2 pairs, so doubling the parts roughly quadruples the interactions. 2 parts1 4 parts6 8 parts28 16 parts120 possible interactions

Brooks's arithmetic: n parts that can each affect the others have n(n − 1)/2 possible interactions, so doubling the parts roughly quadruples what can go wrong between them.

How it works
  1. The same for code. When any function may touch any data, every new part can collide with every old one, and the pairs outgrow the parts.
  2. The wall. A class with private members (data that only the class's own functions may touch) lets the compiler refuse every ordinary touch from outside, cutting the lines that may exist.

The fix needed two things. First, a way to bind data and code together, to say "these functions operate on this kind of thing, and only on this kind of thing." Second, a way to talk about relationships between kinds: a Circle is a Shape; a TextEditor has a Buffer; a Logger can be any object with a log() function. None of this is hard to do in C, by convention. The point is to make the convention enforceable by the compiler, so the cost of breaking it is paid once, at build time, by the person who broke it, rather than once per bug report by everyone.

02 · Stroustrup at Bell Labs

Simula's children, raised on C.

Bjarne Stroustrup arrived at Bell Labs in 1979, not far down the corridor from Ritchie and Thompson. He had just finished a doctoral thesis on distributed systems whose simulator he had written in a language called Simula 67. He loved Simula's classes and hated its speed. C, in the same building, was the inverse: fast and ugly. He spent the next forty years of his life trying to have both.

Simula 67 was designed in 1967 by Ole-Johan Dahl and Kristen Nygaard at the Norwegian Computing Center, widening their simulation language into a general-purpose one. It introduced the modern object-oriented vocabulary: the words class, object, virtual, and even this (referring to "the current object") all originate there, and so do the ideas of inheritance and virtual methods, though Simula spoke of subclasses and virtual procedures. By 1979 Simula was a niche academic language. Stroustrup would graft its ideas onto C, and a program would pay nothing at runtime for a feature it did not use. This zero-cost philosophy became the language's defining trait.

The first version, in 1979, was "C with Classes," a translator that turned the new constructs back into plain C, so that it ran wherever a C compiler did. In 1983 the language was renamed C++, a pun on ++, C's operator for adding one, meaning "C, but more." In 1985 Stroustrup wrote the language's first book, and that year's compiler, Cfront, still worked by turning C++ into C. In 1989 ANSI convened a standards committee; the standard itself, C++98, took nine more years. By 2000 it was the dominant language for systems where C alone could not scale: trading platforms, telecommunications switches, browser engines, game runtimes.

Fig 6.2 · C++, in time
Fig 6.2 · C++, in time A timeline of C++. 1967: Simula 67 introduces classes. 1979: Stroustrup's C with Classes, translated into C. 1985: C++ 1.0, whose compiler Cfront produced C. 1998: C++98, the first standard, with its library. 2011: C++11 adds move semantics, lambdas and auto, and the language's style turns modern. 2017: C++17 lets a struct's fields be unpacked into names. 2020: C++20 adds modules. 2023: C++23 adds a simpler print function. C++ style: classic modern 1967 Simula 67: the first classes 1979 C with Classes, turned into C 1985 C++ 1.0: Cfront, compiles to C 1998 C++98: first standard + library 2011 C++11: move, lambdas, auto 2017 C++17: unpack a struct's fields 2020 C++20: modules 2023 C++23: a simpler print

From Simula's classes to C++23, with the style turning from classic to modern at C++11.

How it works
  1. The aim. Stroustrup (1993): "C++ was designed to provide Simula's facilities for program organization together with C's efficiency and flexibility for systems programming."
  2. The rhythm. Since 2011 a new standard has arrived every three years: 14, 17, 20, 23.

What Stroustrup built was a deal: you may add abstraction without losing speed, because the compiler writes and checks the bookkeeping a C programmer would have written by hand. Section 03 shows what that costs, in memory.

03 · Classes, inheritance, and the vtable

What "OO" looks like in memory.

A class is a struct with functions attached; an object is one value of that type, such as one Circle. A virtual method is an entry in a table of function pointers the compiler manages for you. Inheritance is one struct embedded inside another: a Circle object begins with a complete Shape. Polymorphism is calling through that table at runtime. Each is a layout in memory you can draw on graph paper.

Start with a class without virtual methods. Point, with two integer fields and an equals method, has exactly the same memory layout as struct { int x; int y; } in C. The method compiles to an ordinary function with an extra hidden argument named this, a pointer to the object. Calling p.equals(q) compiles to Point_equals(&p, &q). Nothing extra is stored or run. This is what "zero-cost" means at the smallest scale.

Add the keyword virtual to a method, however, and the choice of function moves to runtime. A Shape pointer may hold a Circle or a Square, and shape->area(), which calls area through the pointer, must run the version for whichever object it points at. To support this, the compiler adds one hidden field to every object of a class with virtual methods: a pointer to a per-class array of function pointers, traditionally called the vtable. Each virtual method gets a fixed slot in that array. The call compiles to two instructions: load the vtable pointer, then jump through slot 0.

Fig 6.3 · A class, an object, and a vtable
Fig 6.3 · A class, an object, and a vtable Shape* s = new Circle(2.0); then s->area(). The object s points at holds a hidden vptr and its field r = 2.0. The vptr points to Circle's vtable, one per class, whose slot 0 points to Circle's area and slot 1 to Circle's draw. A key reads: Shape* is a pointer, -> calls through s, [rdi] is the memory at rdi. The call compiles to two instructions, with rdi holding s: mov rax, [rdi] loads the vptr, and call [rax] jumps through slot 0 to Circle's area. Shape* s = new Circle(2.0); s->area(); object s points at vptr r = 2.0 Circle's vtable 0: Circle's area 1: Circle's draw Circle's area() return π·r·r; Shape*: a pointer ->: call through s [rdi]: memory at rdi what s->area() compiles to (rdi holds s) mov rax, [rdi] load the vptr call [rax] through slot 0

A class with virtual functions gives each object one hidden pointer, the vptr, to the vtable its whole class shares; s->area() costs the two instructions at the bottom, the second an indirect call.

How it works
  1. Who sets it. The constructor, the code that runs as new Circle(2.0) builds the object, writes the vptr, so a Circle always points at Circle's table, whatever type of pointer holds it.
  2. The cost. One extra load and an indirect call; on a modern processor that is barely slower than a direct call.
  3. The C version. A struct of function pointers, kept by hand, is the same design: the Linux kernel's file_operations is one.

Inheritance is layout

A Circle derives from Shape, so its object begins with everything a Shape has, here just the vptr, followed by Circle's own field r. Code compiled for a Shape therefore finds the vptr in the same place in every shape. Fig 6.4 shows what that buys: one call, three classes.

Fig 6.4 · Inheritance: same vtable slot, different function
Fig 6.4 · Inheritance: same vtable slot, different function A function print(Shape* s) calls s->area(); a note under it reads: s points at a shape, -> calls through s. The pointer s is given a Circle with r = 2, then a Square with side 3, then a Triangle with base 4 and height 3. Each object leads to its own class's vtable, where slot 0 holds that class's area: Circle area computes π·r·r and prints 12.57, Square area side² and prints 9, Triangle area ½·b·h and prints 6. The caller never changes. print(Shape* s): s->area() s points at a shape; -> calls through s prints 12.57 prints 9 prints 6 Circle r = 2 vtable slot 0 Circle area π·r·r Square side = 3 vtable slot 0 Square area side² Triangle b = 4, h = 3 vtable slot 0 Triangle area ½·b·h s

One call site, three classes: when s points at a Circle, a Square or a Triangle, the same slot 0 of each class's vtable leads to that class's own area(). The caller never changes; that is polymorphism, code written for the general kind, Shape, not for any one shape's formula.

How it works
  1. Same layout. Every class derived from Shape puts area() in the same slot of its vtable, so the call compiles to the same two instructions.
  2. New shapes for free. A Hexagon added next year works with print() unchanged and without recompiling.
  3. The rule. Declaring virtual double area() = 0; in Shape gives area no body of its own: Shape itself cannot be created, only its subclasses.

Encapsulation as a tool

The keywords private and public let a class hide its fields, forcing access through its methods. This is enforced at compile time only. At runtime, a private field is just a field, and a determined caller can still reach it through a pointer to the object's bytes, as any C function could reach into a struct. This hiding, encapsulation, is a discipline the language helps you keep, not a security boundary. Used well, it lets you change a class's internals freely, knowing the rest of the codebase cannot have grown to depend on them.

04 · RAII

The signature C++ idiom.

Classes came from Simula, but Stroustrup's most original contribution was a rule for resources: the memory, open files and locks a program takes and must give back. Tie each one to an object on the stack, so that initialising the object acquires the resource and the compiler releases it when the object goes out of scope, at the end of the block of code that declared it. The acronym is RAII, Resource Acquisition Is Initialisation.

Recall from Chapter 5 that C makes you the bookkeeper. Every malloc needs a free, every opened file a close, every locked mutex an unlock, on every way out of the function. C++ adds one more way out, the exception: an error that abandons the function, and each caller in turn, until one handles it. C's answer is one cleanup block at the end, with every early exit jumping to it by goto. It works until a later edit adds an exit that skips it.

The C++ answer is an object whose constructor (run when the object is created) acquires the resource and whose destructor (run when it is destroyed) releases it. Place that object on the stack and the compiler emits a destructor call at every exit from its scope: a normal return, a break out of its loop, or an exception thrown three frames down, as it unwinds the stack. The bookkeeping can no longer be forgotten, because no human writes it.

Fig 6.5 · RAII: scope acquires, scope releases
Fig 6.5 · RAII: scope acquires, scope releases A C++ function opens file x with std::ifstream, allocates a buffer buf with std::vector and locks a mutex m with std::lock_guard, then calls parse, which fills buf and may throw, then write, and returns. Comments name each line's job, and std:: marks the standard library. Each object acquires its resource as its line runs. On return 0, the destructors run in reverse order: m is unlocked, buf freed, f closed. In a second run parse throws an exception, and the same three destructors run in the same reverse order as the exception leaves the function. No cleanup code is written. int do_thing() {// std:: standard library   std::ifstream f("x");// opens file x   std::vector<char> buf(1024);// a buffer   std::lock_guard lock(m);// m: a mutex   parse(buf, f);// fills buf; may throw   write(buf);   return 0; } ifstream ffile open ifstream ffile open vector buf1024 bytes vector buf1024 bytes lock_guardm locked lock_guardm locked return 0: leaving scope parse() throws destructor: unlock m destructor: unlock m destructor: free buf destructor: free buf destructor: close f destructor: close f return 0: all released, in reverse parse() threw: released the same way

Each resource is held by an object on the stack (buf's 1024 bytes sit on the heap; the vector on the stack owns them), acquired as its line runs and released by its destructor in reverse order. A normal return and an exception thrown by parse() unwind the same way.

How it works
  1. Why reverse. Later resources may depend on earlier ones, so the compiler destroys them in the opposite order to construction.

std::vector is RAII for a heap-allocated array, std::unique_ptr for a single heap object, std::lock_guard for a mutex and std::ifstream for an open file. The standard library is largely a catalogue of RAII wrappers around C resources, built on one principle: resources are objects, and objects clean up after themselves.

"C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off."

Bjarne Stroustrup
05 · The performance contract

What "zero-cost" actually means.

C++ makes a two-part promise: you do not pay for what you do not use, and what you do use is as fast as anything you could have written by hand. The slogan is zero-cost abstraction, and it is slightly misleading. Most of the cost falls outside the running program: compile time, build complexity, larger programs, and programmers who must understand both the abstraction and what it compiles to.

Consider std::sort. C's qsort receives its comparator, the function that says which of two items comes first, as a function pointer, so every comparison is a call into code the compiler could not see when it built qsort. std::sort takes the comparator (by default, <) as a template argument: a template is a recipe the compiler fills in afresh for each type or function it is given. Here it builds a copy of std::sort for that comparator and inlines the comparison, writing it into the loop as two instructions instead of a call. Sorting a million integers, std::sort typically runs about three times faster than qsort.

Fig 6.6 · qsort vs std::sort, same job, different cost
Fig 6.6 · qsort vs std::sort, same job, different cost The path of one comparison. C's qsort, sorting n ints, calls the comparison function cmp through a pointer, so each comparison is five steps: call, load the two values, compare, return, test the result. C++'s std::sort, sorting all of v, is compiled for the exact comparison, which is inlined, written into the loop: compare and branch, two steps. A note says std:: marks the standard library. A marker stepping through both paths at the same rate completes two comparisons in qsort while std::sort completes five; a count beside each path keeps score, and the still shows the final count. C · compiled once, in the library qsort(v, n, size, cmp); // cmp: compare two call load compare return test 5 steps, through a pointer comparisons: 2 comparisons: 0 comparisons: 1 C++ · compiled for this comparison std::sort(v.begin(), v.end()); // all of v compare branch 2 steps, inside the loop comparisons: 5 comparisons: 0 comparisons: 1 comparisons: 2 comparisons: 3 comparisons: 4 std:: marks the standard library

One comparison traced on each path: five steps through a pointer against two written into the loop, so in the same time std::sort finishes five comparisons to qsort's two. The price is a separate copy of the sort for each comparison type.

How it works
  1. Templates. std::sort is compiled at your call site with the comparison's type known, so the optimiser folds the comparison into the loop.

The same trade runs through the whole language. Where C passes a function pointer, C++ prefers a template. Where a value depends only on inputs known before the program runs, constexpr lets the compiler work it out, so the program carries only the answer. Each moves work from the running program into the build, where it is paid once.

06 · Modern C++

The language that ate its own complexity.

Through the 1990s and 2000s, C++ accumulated a reputation as the language with every feature, none of which were quite right. C++11, the first major standard in thirteen years, made most of the hated patterns unnecessary. Smart pointers replaced hand-written new and delete (C++'s version of malloc and free), move semantics replaced expensive copies, auto let the compiler work out types, and lambdas let short functions be written where they are used. Almost none of the old features were removed; the new ones were so much better that competent C++ stopped using the old.

Smart pointers

A hand-written new in modern C++ is rare to the point of suspicion. Heap objects are held instead by smart pointers, RAII objects whose type says who owns the object, the one responsible for freeing it. A std::unique_ptr is the only owner: it cannot be copied, only handed on, and the object dies with it. A std::shared_ptr shares ownership: a reference count records how many shared_ptrs point at the object, and the object is freed when the count reaches zero. A std::weak_ptr watches without owning; to use the object it must first obtain a shared_ptr, which fails if the object is gone.

Fig 6.7 · Three pointer flavours, three ownership semantics
Fig 6.7 · Three pointer flavours, three ownership semantics Three lifecycles. unique_ptr: one owner, p; when p goes out of scope the object is freed. shared_ptr: two owners, a and b, and a count of 2; when a goes the count is 1, when b goes it is 0 and the object is freed. weak_ptr: an owner s and a weak observer w; when s goes the object is destroyed even though w is still watching it, and w reports that it has expired. Two notes state the steps: a goes, count 1, b goes, 0; s goes, freed, w expired. unique_ptr · one owner owner p object freed shared_ptr · counted owners owner a owner b objectcount 2 objectcount 1 freed then a goes: 1, b goes: 0 weak_ptr · watches, does not own owner s weak w w: expired object freed then s goes: freed, w expired

Three lifetimes: one owner, counted owners, and an observer left holding an expired pointer.

How it works
  1. The costs. A unique_ptr is as cheap as a raw pointer; a shared_ptr pays for an atomic count updated on every copy.
  2. Making one. std::make_unique<T>(...) and std::make_shared<T>(...) create the object and its owner in one step, so no bare new is written.
  3. Breaking cycles. Two objects holding shared_ptrs to each other keep each other's count above zero; making one link a weak_ptr lets both be freed.

Move semantics

A std::vector is three pointers into a buffer on the heap (where it begins, where its elements end, and the limit of the space it has reserved), so a copy must allocate a second buffer and copy every element into it. C++11 added a way to move it instead: to declare "I am done with this; take it." The new vector copies the three pointers and the old vector's are set to null, so a million elements change hands by copying three pointers. A function asks for a move with an rvalue reference, written T&&, a parameter type that accepts only a value whose owner is done with it: a temporary result, or an object marked with std::move.

Fig 6.8 · A move is "I am done with this; take it"
Fig 6.8 · A move is "I am done with this; take it" auto v2 = std::move(v1); Two vector objects, v1 the old vector and v2 the new one, each three pointers: begin, end and limit (the capacity), with end and limit 4 MB past begin. Before the move, v1's pointers lead to a heap buffer of one million ints, 4 MB, and v2 is empty. The move copies v1's three pointers into v2 one by one, so v2 now leads to the same buffer, then sets v1's pointers to null. No int is copied and nothing is allocated; v1 is left empty but valid. A note says std:: marks the standard library. auto v2 = std::move(v1); v1: old vector v2: new vector begin = 0xA000 begin = null begin = null begin = 0xA000 end = begin+4MB end = null end = null end = begin+4MB limit = begin+4MB limit = null limit = null limit = begin+4MB heap: 1,000,000 ints, 4 MB 3 pointers copied, 3 nulled, 0 ints moved std:: marks the standard library

Before and after std::move: the 4 MB buffer never moves, only the three pointers that lead to it. The cost is the same for ten elements or ten million, and v1 is left empty but valid.

How it works
  1. A copy instead. auto v2 = v1; would allocate another 4 MB and copy every int into it.
  2. std::move moves nothing. It only marks v1 as finished with, so the compiler picks the moving constructor over the copying one.
  3. Returning by value. A local vector returned from a function is moved out, or built in place, so returning large containers is cheap.

Seven standards

Fig 6.9 · C++ standards · the language eating its own complexity
Fig 6.9 · C++ standards · the language eating its own complexity Adding up a vector in three eras of C++. C++98: an explicit iterator loop over three lines, i pointing at each item. C++11: a range-for, for (int x : v) total += x, x being each item. C++23: auto total = std::ranges::fold_left(v, 0, std::plus{}), 0 plus each item, in one call. Plain notes gloss the notation: ++i moves to the next item, *i is the item i points at, fold_left starts at 0 and adds each item, and :: names a part of a library, std:: being the standard library. Beneath, the seven standards: 98, 03, 11, 14, 17, 20 and 23. one task, three eras: add up a vector <int>: of intsC++98 int total = 0; for (vector<int>::iterator i = v.begin();      i != v.end(); ++i) total += *i; ++i: next item · *i: the item i points at C++11 int total = 0; for (int x : v) total += x;x is each item C++23 auto total = std::ranges::fold_left(     v, 0, std::plus{}); fold_left: 0 + each item; plus{} is the + :: names a part; std:: is the standard library the standards 98 03 11 14 17 20 23

Seven standards in twenty-five years, and the same task needs less bookkeeping each time: an iterator loop in C++98, a range-for in C++11, one call over the whole vector, with no iterators to name, in C++23.

How it works
  1. The two big ones. C++11 and C++20 (concepts, modules, ranges, coroutines) each changed what "modern C++" means.
  2. The books. Scott Meyers wrote Effective Modern C++ (2014) as a companion to his Effective C++, for the features C++11 and C++14 added.
  3. Little is removed. auto_ptr, gone in C++17, is a rare removal.

The seam to Chapter 7

C and C++ are both languages that face the machine. They give the programmer enormous power (memory reached directly through pointers, predictable cleanup, zero-cost abstraction) and demand in return careful attention to what the machine actually does. By the late 1980s C was the language of most new systems software, with C++ spreading behind it, and many programmers took this as the trade every language must make. In 1989, over a Christmas holiday in Amsterdam, Guido van Rossum asked the opposite question. What if a language did not face the machine at all?

"Within C++, there is a much smaller and cleaner language struggling to get out."

Bjarne Stroustrup, The Design and Evolution of C++, 1994

Where C++ still bleeds. Smart pointers and RAII closed most of the manual-memory wounds, but three kinds survive. Use-after-move: a moved-from object is left valid but in a state the standard does not fix (Fig 6.8's vector happens to be empty), so code that keeps using it as if it still held its data is wrong, and the compiler does not warn. Vtable corruption: when a polymorphic object is freed and its chunk is refilled with attacker-chosen bytes, the next virtual call through the stale pointer reads those bytes as the vtable pointer and jumps to attacker-chosen code, the modern descendant of the buffer overflow from Chapter 3. Iterator invalidation: an iterator marks a place in a vector, and once the vector outgrows its buffer and takes a bigger one, every iterator points into freed memory. The security flaws still found in C++ codebases drive the slow migration to Rust in security-critical paths.

Chapter 07

The Language
That Chose Humans
Over Machines

C and C++ are languages designed around the machine. Python is designed around the programmer. The trade is explicit: your code runs ten to a hundred times slower, and you get back code that reads like a plan written in plain words. For most problems most programmers actually have, that trade is the right one.

TopicsInterpretation · Dynamic typing · The global interpreter lock · The two-language strategy
Era covered1989 → present
Listinglines 2414–3199 · python3 readable.py
Chapter 07 hero · The Language That Chose Humans Over Machines Python code for x in numbers:     print(x * x) reads almost like English INTERPRETER runs your code step by step ~1M lines of C underneath 10 to 100 times slower than C NumPy number arrays PyTorch machine learning SciPy science maths heavy maths handed down to fast libraries
01 · Compiled vs interpreted

Two pipelines, two trades.

The first Lisp interpreter ran on an IBM 704 at MIT around the turn of 1959. John McCarthy had written a paper defining a new language in terms of itself (its core function, eval, evaluated Lisp expressions in Lisp) but had not implemented it. A research assistant named Steve Russell, who had spent months hand-translating Lisp functions into machine code, realised that eval looked like working code rather than mathematical theory, and spent a few weeks turning it into machine code by hand. The result was a working interpreter: a program that read each Lisp expression at runtime and executed it directly, without ever producing a separate binary. The BASIC of the first microcomputers, a decade and a half later, arrived the same way: an interpreter squeezed into the machine's read-only memory, translating each line as it ran. So the question of when to translate a program into machine instructions is older than C: once, in advance, or every time it runs.

A compiled language passes through a pipeline before it ever runs. A C compiler splits each source file into tokens (its words and symbols), arranges them into a tree of the program's structure, the abstract syntax tree (AST), and translates that tree into optimised machine code; a linker then joins the compiled files and the library code they use into one executable. All of this happens in advance, on the developer's machine. When a user runs the program, the CPU executes the instructions directly, so speed is bounded only by the hardware and the compiler's cleverness.

An interpreted language runs most of the same pipeline, but at runtime, on the user's machine. Python source becomes tokens, then an AST, then bytecode: a sequence of small, simple opcodes for an imaginary processor. Imported modules (code files that a program loads with import) keep their bytecode in a cache until their source changes; the interpretation is paid for on every run. CPython, the standard interpreter, is a loop in C that reads each opcode and runs the C code for it. The CPU executes the interpreter; the interpreter executes your code. Every Python statement becomes dozens to hundreds of CPU instructions in that loop, plus the actual work. In return, a changed line runs at once, with no build to wait for.

Fig 7.1 · The two pipelines, side by side
Fig 7.1 · The two pipelines, side by side Two programs, each run three times. The C program is compiled and linked once, on the developer's machine, and every run executes native machine code. The Python module is parsed and compiled to bytecode the first time it is imported, and the bytecode is saved in a .pyc file; later runs load it from the cache, but every run, the first and the later ones, goes through CPython's eval loop, which interprets the bytecode one instruction at a time. run 1 of 3 run 2 of 3 run 3 of 3 C · main.c compile link native code once, before shipping every run Python · an imported module parse bytecode eval loop from .pyc cache from .pyc cache first run, then cached every run

C pays for translation once, on the developer's machine, and every run executes native code; Python translates on the user's machine, caches an imported module's bytecode in a .pyc file, and interprets it on every run. Python ships source that runs wherever there is an interpreter, and pays in cycles each time it runs.

How it works
  1. C. The compiler parses, optimises and emits machine code; the linker joins the pieces into one executable for one kind of machine.
  2. Python. CPython parses a module to a syntax tree, compiles it to bytecode and caches that in __pycache__ when it is imported (a script run directly is recompiled each time); its eval loop runs one bytecode instruction at a time.
  3. In between. Just-in-time compilers (PyPy, and CPython 3.13's experimental JIT) turn hot bytecode (the parts that run most often) into machine code while the program runs.
02 · Van Rossum 1989–91

A Christmas hobby that ate the world.

Over Christmas 1989 the Dutch programmer Guido van Rossum, who worked at the Centrum Wiskunde & Informatica (CWI) in Amsterdam, wanted a hobby project for the week his office was closed. He had spent four years on ABC, a language CWI built for teaching, which had failed to gain traction outside CWI. Working at home over the break, he started writing an interpreter for a new language that borrowed ABC's clarity and added the things ABC had refused: modules, exceptions, and a way to call code written in C. He named it Python, after the British comedy troupe Monty Python. In 1991 he posted it to Usenet's discussion groups, and it spread into data science, scientific computing and many of the world's introductory programming courses.

Python's design choices trace to instincts that Tim Peters wrote down in 1999 as the Zen of Python. Readability counts: indentation marks each block, as braces do in C, so the layout you see is the structure the interpreter sees. Explicit is better than implicit: 1 + "a" is an error rather than a guess, and self, the this that C++ hides, is spelled out as every method's first parameter.

Fig 7.2 · Python's slow conquest
Fig 7.2 · Python's slow conquest A timeline of Python. 1980s: ABC, a teaching language at CWI in Amsterdam. 1989: Guido van Rossum starts Python over Christmas. 1991: version 0.9.0 is posted to Usenet. 1994: Python 1.0. 2000: Python 2.0. 2008: Python 3.0 breaks compatibility, and Python 2 and Python 3 run side by side for twelve years until Python 2 is retired in 2020. 2024: Python 3.13 offers an optional build without the global interpreter lock. Python 1 and 2 3 1980s ABC, a teaching language at CWI 1989 van Rossum starts Python 1991 0.9.0 posted to Usenet 1994 Python 1.0 2000 Python 2.0 2008 3.0 breaks compatibility 2020 Python 2 retired 2024 3.13: optional build, no GIL

Python's defining event is the split: Python 3 broke compatibility in 2008, and Python 2 lived beside it for twelve years before it was retired in 2020; in 2024 Python 3.13 first offered a build without the lock of Section 04.

How it works
  1. The name. Van Rossum (1996): "I chose Python as a working title for the project, being in a slightly irreverent mood (and a big fan of Monty Python's Flying Circus)."
  2. What 3.0 fixed. Text became Unicode (characters from any alphabet, where Python 2 text was plain bytes), 1/2 became 0.5 (it was 0), and print "x" became print("x"): each one broke old code.
  3. The inheritance. From ABC, Python took indentation as structure and a taste for being easy to read.
03 · Dynamic typing

Types are properties of values, not variables.

In C and C++, a variable has a type, declared once and fixed forever. In Python, a variable is just a name that can refer to any value, and a value carries its own type. Write x = 5, then x = "hello", then x = [1, 2, 3], and each is legal. The interpreter checks those types at every operation, paying for that check in cycles. The programmer is paid back with fewer declarations to write and get wrong.

Statically typed languages (C, C++, Rust, Go, Java) check types once, at compile time. Hand a string to a function that wants an integer and the compiler refuses; the program never runs at all. Dynamically typed languages (Python, Ruby, Lisp) check types at runtime. The same program starts without complaint, runs until it reaches the mistake, and dies there with a TypeError.

Python pushes dynamic typing to its logical limit with duck typing: "If it walks like a duck and quacks like a duck, it is a duck." An object fits wherever it has the methods the code calls, whatever its class. A function that calls obj.read() works on any object that has a read() method (a file, an HTTP response, a stand-in for testing), with no shared parent class required. The price is that the fit is checked only at runtime; the benefit is that you can combine objects in ways their designers never anticipated.

Fig 7.3 · Duck typing: behaviour, not lineage
Fig 7.3 · Duck typing: behaviour, not lineage A function announce(thing) prints thing.speak(); a note beside its def line says the line defines a function. It is called three times, and each printed result appears at the end of the print line. With a Duck, Python finds a speak method on the object and prints quack. With a Robot, an unrelated class with no shared parent, it finds speak again and prints BEEP. With the integer 42, there is no speak, and the call fails at that moment with AttributeError. def announce(thing):     print(thing.speak()) defines a function quack BEEP AttributeError class Duck speak() "quack" class Robot speak() "BEEP" 42, an int no speak no shared parent class, no declared interface thing

announce works on any object that has a speak method: Python looks the name up on the object, at the moment of the call. A Duck and an unrelated Robot both answer; an int has no speak, and the mistake surfaces only when that call runs, as AttributeError.

How it works
  1. The strength. Any object with the right method fits, including ones from libraries written after the function was.
  2. The weakness. The contract "must have speak()" is checked by running the code, not by a compiler beforehand.
  3. The middle way. Type hints are optional type labels written in the code. Python ignores them when the program runs, but a checker such as mypy reads them first; a typing.Protocol lists the methods an object must have, so the contract "must have speak()" is checked beforehand, without any shared parent class.
04 · The GIL and concurrency

One Python thread runs at a time, for reasons that are running out.

A program can run several threads, streams of instructions that share its memory and can run on separate cores at once. Python's threads are real ones, but CPython has a single global lock, the GIL (Global Interpreter Lock), that lets only one thread at a time execute Python bytecode. On an eight-core CPU, a program whose threads all compute in Python leaves seven cores idle, though a thread waiting on the network, or calling a C library that works on its own data, can let go of the lock. This sounds like a bug, but it follows from a deliberate design decision of 1992, whose bill came due only when the cores multiplied.

The reason is reference counting. Every Python object carries a count of how many references point to it (names, list slots, attributes); when the count drops to zero, the object is freed. Updating the count must be atomic, and here is what happens if it is not: two threads decrement at once, both read 2 and both write 1, and one decrement is simply lost. An object that should be dead now lives forever. Lose an increment instead and the count hits zero early: the object is freed while a thread still holds it, and Chapter 5's use-after-free arrives in a language that promised you would never see one. The GIL is the simplest possible solution: Python code runs one thread at a time, so counts cannot race. Removing it has been tried many times, but earlier attempts made every count update atomic or locked, and slowed single-threaded code, which is most code, by a large margin. PEP 703, accepted in 2023, lets the thread that owns an object update its count without atomic instructions, which has cut that slowdown to about 5–10%. Since Python 3.13 there has been an optional build without the GIL, officially supported from 3.14, and whether it becomes the default is undecided.

Fig 7.4 · Two Python threads, one GIL
Fig 7.4 · Two Python threads, one GIL Thirty milliseconds of two Python threads. With the global interpreter lock, only the thread holding it runs: thread 1 runs for 5 ms, then thread 2, then thread 1, the lock passing at the 5 ms switch interval. From 15 ms thread 1 waits on I/O and releases the lock, so thread 2 runs for those 10 ms; at 25 ms thread 1's I/O is done and it waits for the lock, which thread 2 hands over when its 5 ms interval runs out, at 30 ms. In Python 3.13's optional free-threaded build there is no such lock: both threads run at the same time, except while thread 1 waits on I/O. default build: one lock T1 T2 GIL 3.13 free-threaded build T1 T2 0 10 20 30 ms runs waits on I/O waits for GIL

With the GIL, two CPU-bound threads take turns, the lock passing between them every 5 ms, so only one runs at a time; a thread waiting on I/O releases it, and then the other gets real work done. Python 3.13's optional free-threaded build removes the lock, and both threads run at once.

How it works
  1. The interval. sys.getswitchinterval() is 0.005 seconds by default: after that, the running thread is asked to hand the lock over.
  2. Removing it. PEP 703's free-threaded build gives each object an owner thread that updates its count cheaply; other threads use slower atomic updates, and per-object locks guard only lists, dicts and sets. In 3.13 single-threaded code runs about 40% slower in it, in 3.14 about 5–10%.

A thread that is waiting needs no core, so a program that spends its time on network replies loses little to the GIL. For thousands of such waits, where a thread apiece would cost too much memory and too many switches, Python's asyncio shares one thread among many tasks, each handing it over only where it says await, as the Bridge's cooperative programs yielded (Fig 7.5).

Fig 7.5 · asyncio: cooperative scheduling on one thread
Fig 7.5 · asyncio: cooperative scheduling on one thread One thread runs an event loop with three tasks. Task A runs until it awaits a network reply; the loop switches to task B, which runs until it awaits; then task C. While all three wait, their waits overlap and the loop sleeps until the operating system reports a reply. Then A resumes, then B, then C, each finishing. Only one task ever runs at a time, but the three waits happen together. thread A A B B C C idle task A await task B await task C await runs on the thread awaits a reply

One thread runs an event loop: each task runs until it awaits a network reply, and the loop switches to another that is ready, so the waits overlap although only one task runs at a time. A task can be interrupted only where it says await, so most shared state needs no locks.

How it works
  1. The history. asyncio arrived in Python 3.4 (2014); the async def and await keywords followed in 3.5.
  2. The idle gap. When every task is waiting, the loop asks the operating system (epoll, kqueue) which sockets are ready, so one thread can watch thousands of connections.
  3. The catch. A task that computes without awaiting holds up every other task, and async spreads to every function that calls an async one.
05 · How Python is built

CPython is one million lines of C running your script.

"Python" is a language specification. CPython is the reference implementation, the one other implementations follow and the one that runs when almost anyone types python. It is roughly one million lines of C, maintained since 1991, first by Guido van Rossum and now by a global team of core developers.

CPython's pipeline has four stages. The tokeniser splits the source file into tokens: names, numbers, operators, and indentation changes. The parser assembles tokens into an abstract syntax tree. The compiler walks the tree and emits a list of bytecode instructions for a stack machine, which computes on the values at the top of a stack (Fig 7.6). The evaluator, the loop from Section 01, lives in a C file called ceval.c and has a case for every bytecode instruction.

Fig 7.6 · CPython's pipeline, in detail
Fig 7.6 · CPython's pipeline, in detail The line a + b * c, with a = 2, b = 3 and c = 4, goes through CPython's front end: tokens, a syntax tree, bytecode, then the eval loop. The bytecode is LOAD_NAME a, LOAD_NAME b, LOAD_NAME c, BINARY_OP *, BINARY_OP +. Running it, the loads push 2, 3 and 4 onto the value stack; BINARY_OP * pops 3 and 4 and pushes 12; BINARY_OP + pops 2 and 12 and pushes 14. a + b * c a=2, b=3, c=4 tokens AST bytecode eval loop bytecode LOAD_NAME a LOAD_NAME b LOAD_NAME c BINARY_OP * BINARY_OP + value stack 2 23 234 212 14

CPython turns a + b * c into bytecode for a stack machine: three loads push 2, 3 and 4; BINARY_OP * pops 3 and 4 and pushes 12; BINARY_OP + pops 2 and 12 and pushes 14. The eval loop does exactly this, one instruction at a time, for every line of Python.

How it works
  1. See it yourself. import dis; dis.dis("a + b * c") prints this bytecode, between a RESUME and a RETURN_VALUE.
  2. Since 3.11. One BINARY_OP with an argument replaced BINARY_ADD, BINARY_MULTIPLY and the rest, and the interpreter swaps a faster, specialised instruction in for one that keeps meeting the same types, such as BINARY_OP_ADD_INT.
  3. The loop. ceval.c jumps straight from each instruction to its C handler.
Fig 7.7 · Why Python is slow (and why nobody minds)
Fig 7.7 · Why Python is slow (and why nobody minds) The work behind a + b. In C, where the compiler knows both are ints, it is one instruction: add eax, esi. In CPython's generic path it is a list of steps, each several machine instructions: dispatch to the opcode handler, pop two object pointers, find the type's add function, check both are ints, read the values out of both, add them, make an int object for the result, update reference counts, push the result. the work behind a + b C add eax, esi 1 instruction CPython dispatch to the opcode handler 1 pop two object pointers 2 find the type’s add function 3 check both are ints 4 read the values out of both 5 add them 6 make an int object for the result 7 update reference counts 8 push the result 9 each step is several instructions, every time

C's compiler knows both values are ints and emits one add; CPython learns what they are only at runtime, so every + unpacks two objects, checks their types, adds, and packs a new object. Many programs spend most of their time waiting on disks, networks or libraries written in C, and there the gap does not show.

How it works
  1. Boxes. Every Python value is an object on the heap with a type and a reference count; even a small int is a pointer to one.
  2. Since 3.11. The specialised BINARY_OP_ADD_INT of Fig 7.6 skips step 3's lookup once a + has kept seeing ints, though not the boxes.
  3. JIT compilers. PyPy compiles hot loops to machine code with the types it has observed, removing most of the steps.
06 · Why Python won data science

The two-language strategy.

Python is slow, and numerical work is the most cycle-hungry thing computers do, yet Python is the everyday language of data science. It got there through one design pattern: write the human-facing code in Python, write the loops that do the arithmetic in C, and call them through the door to C that Python was built with. NumPy, a library for arrays of numbers, is built this way: multiplying two 1000 × 1000 matrices (grids of numbers) takes a billion multiply-adds, because each of the million cells of the answer is 1000 products added up, and NumPy hands the job to BLAS, a decades-old library of hand-tuned routines (Fig 7.8).

Fig 7.8 · How NumPy actually does work
Fig 7.8 · How NumPy actually does work One line of Python, C = np.dot(A, B), where A and B are matrices, descends four layers. NumPy's C code checks the types and shapes and picks a routine. A BLAS matrix-multiply routine does the work in cache-sized tiles with vector instructions, which the CPU's vector units execute several doubles at a time (four or eight on typical x86 chips). The Python and NumPy layers take microseconds; for a 1000 by 1000 matrix the BLAS work takes milliseconds. The result comes back up as one NumPy array. Python C = np.dot(A, B) A, B: matrices microseconds NumPy's C code check types and shapes, pick a routine microseconds BLAS: matrix multiply cache-sized tiles, vector instructions milliseconds CPU vector units several doubles per instruction back in Python: one array

One line of Python descends through NumPy's C code into a BLAS routine that the CPU's vector units execute, and comes back as a single array. The Python layers cost microseconds; for a 1000 × 1000 multiplication the BLAS work takes milliseconds, so the interpreter all but disappears from the bill.

How it works
  1. BLAS. The Basic Linear Algebra Subprograms began in 1979, and matrix routines such as dgemm joined in 1990. NumPy's Linux and Windows builds ship OpenBLAS; on Apple Silicon Macs it can use Apple's Accelerate.
  2. Blocking. dgemm multiplies the matrices in tiles small enough to stay in cache, so each number fetched from memory is used many times.
  3. Vectors. On x86, AVX2 works on 4 doubles at once and AVX-512 on 8; Arm's NEON on 2. The work is also split across every core.
Fig 7.9 · The same matmul, two ways
Fig 7.9 · The same matmul, two ways Multiplying two 1000 by 1000 matrices, timed on a logarithmic axis from one millisecond to a thousand seconds. np.dot finishes in about 0.012 seconds. Pure Python, with three nested loops, finishes in about two minutes, roughly ten thousand times later. 1000 × 1000 matrix multiply, time to finish np.dot ≈ 0.012 s pure Python ≈ 2 min 1 ms 0.1 s 10 s 1000 s log scale: each tick is ×10

Multiplying two 1000 × 1000 matrices takes about two minutes in pure Python, a billion multiply-adds each paying the interpreter's toll, and about a hundredth of a second with np.dot. On this logarithmic axis that is four steps of ten.

How it works
  1. The count. Three nested loops over 1000 make 109 multiply-adds, and in pure Python each one runs the steps of Fig 7.7.
  2. The other side. np.dot hands the whole job to BLAS (Fig 7.8). Exact times vary with the machine; the ratio stays in the thousands.
  3. The rule. Keep Python out of the inner loop: let it call code that loops in C.

The verdict

Python lost to the "fast" languages on speed and won data science anyway, because its users were physicists, biologists, traders and machine-learning researchers with problems to solve rather than loops to tune, and Python let them think about the problem instead of the language.

This is, almost exactly, the trade C++ refused to make. Stroustrup held that an abstraction must cost no more at runtime than the code you would have written by hand; van Rossum held that its real cost was paid in human attention, and would let the machine do a hundred times the work to save a programmer five minutes.

"There should be one-- and preferably only one --obvious way to do it. Although that way may not be obvious at first unless you're Dutch."

The Zen of Python, Tim Peters, 1999

The sharpest edge is one line long. pickle.loads(data), which rebuilds a Python object from the bytes pickle saved it as, looks like a parser and is an interpreter: a pickle is a small program for a stack machine, and the machine can construct any Python object, including one whose construction calls os.system(), which runs a command on the computer. Unpickling attacker-controlled bytes is therefore arbitrary code execution by design, not by bug. Several web frameworks that accepted pickled session cookies have been bitten by this; the only real fix is to never deserialise pickled data you didn't sign. Python's other edges are the same property (data becoming code with almost no friction) met at different doors: eval() and exec() on user input repeat the same data-as-code mistake that gives Chapter 13's SQL injection its name, and a package named request on PyPI, the public index Python libraries are installed from, one keystroke from the popular requests, runs whatever it likes the moment someone installs it, since installing runs the package's own setup code. Dynamism is convenience; dynamism is also attack surface.

End of Part II
EOF · 3199 lines · 4 modules

The software layer is built.

Part II read the kernel as code, not silicon, then three languages that strike different deals with the same machine. C stays close to it. C++ scales C up to systems too big for one head. Python gives up a factor of ten to a hundred in speed for readability, and hands its fast parts to C. All three are still in production.

Part III steps out of the box entirely. The wire leaves the CPU, crosses the room, and joins the network, where a voltage on copper becomes light in glass, crossing a continent. It is, once again, a story about people: Shannon, Baran, Davies, Cerf, Kahn, Berners-Lee, Eich.

Continue to Part III: The Network