OSOperating Systems

Block C · Topics 13–17

Memory

The largest illusion an OS maintains. Every program believes it owns a vast, private, contiguous address space; almost none of that is true, and the machinery that keeps the lie convincing is the most examined part of the subject.

13

Memory management & fragmentation

allocationfragmentationfirst fitbuddyslab
Why it exists

Physical RAM is one finite array of bytes and many programs want pieces of it, at different sizes, at unpredictable times, for unpredictable durations. Whatever strategy you pick, you eventually end up with memory that is free but unusable — and managing that waste is the whole topic.

The two fragmentations — get these the right way round

External fragmentation

Total free memory is sufficient, but it's split into pieces too small to satisfy any single request. You have 100 MB free and cannot allocate 60 MB because it's in twelve scattered holes.

Caused by: variable-size allocation.
Fixed by: compaction (expensive, needs relocatable addresses), or paging — fixed-size blocks can't leave awkward holes.

Internal fragmentation

Memory allocated to a process that the process cannot use, because the allocator rounds up. A 4100-byte request in 4 KB pages consumes two pages and wastes 4092 bytes inside the second one.

Caused by: fixed-size allocation.
Fixed by: smaller blocks, or size-class allocators — but smaller blocks mean bigger page tables. It never fully goes away.

The relationship is the insight: paging trades external fragmentation for internal fragmentation, and that's a good trade because internal waste is bounded (at most one page per allocation, on average half a page) while external waste is unbounded and unpredictable.

Placement strategies

StrategyPicksVerdict
First fitThe first hole big enoughFastest; good enough in practice. Fragments the low end of memory over time.
Next fitFirst fit, resuming from last positionSpreads wear more evenly; slightly worse fits
Best fitThe smallest hole that fitsSounds optimal, performs worse — it manufactures tiny unusable slivers, and it scans the whole list
Worst fitThe largest holeAims to leave usable remainders; in practice destroys large blocks. Rarely used.

The counter-intuitive result worth stating: first fit generally beats best fit on both speed and fragmentation. "Best" refers to the tightness of the fit, not the outcome.

Real allocators

AllocatorHowWhere
Buddy systemSplit memory in halves recursively; free blocks merge with their "buddy" if it's also freeLinux physical page allocator. Fast split/merge; up to 2× internal waste from power-of-two rounding.
Slab / slubPre-carved caches of same-size objects, kept initialised and cache-alignedLinux kernel objects (inodes, task_structs). Near-zero fragmentation for fixed types, and allocation is a pointer pop.
Size-class (tcmalloc, jemalloc)Per-thread caches of size classes, backed by central arenasUserspace malloc. Per-thread caches avoid lock contention — the reason they beat glibc under threading.
Arena / bumpAllocate by advancing a pointer; free the whole arena at onceRequest-scoped work, compilers, game frames. Trivially fast; no individual free.
Why "free" memory doesn't return to the OS

A very common production puzzle. free() returns memory to the allocator, not usually to the kernel — the allocator keeps it for the next request, because syscalls are expensive. So RSS stays high after a spike even though your program "freed everything". This is not a leak, it's caching; the allocator only returns memory when a large contiguous region at the top of the heap becomes free (brk can only shrink from the end) or when it used mmap for a big block. Being able to explain this separates "the process is leaking" from "the allocator is holding".

14

Paging & the TLB

page tableMMUTLBmulti-level
Why it exists

Paging solves three problems at once, which is why it won. Fixed-size blocks eliminate external fragmentation. Indirection means a process's memory need not be physically contiguous, so any free frame will do. And because every access goes through a table the kernel controls, isolation and permissions come along free.

The mechanism

Split the virtual address space into pages and physical memory into frames of the same size (4 KB typically). A page table maps page → frame. The MMU does the translation in hardware on every single memory access.

32-bit virtual address, 4 KB pages page number 20 bits offset 12 bits Page table 3 → frame 0x2A 4 → frame 0x11 5 → (not present) per process, in RAM frame number from the table offset copied as-is = physical address the offset is never translated — which is why page size = offset bits TLB — the translation cache 64–1536 entries, fully associative, ~1 cycle. Hit (>99%): translation is free. Miss: walk the page table, then cache it. Without a TLB, paging would be unusable A 4-level table means 4 memory reads to translate, then 1 to fetch the data — every access 5× slower. The TLB is not an optimisation; it is what makes the design viable.
Page number is translated, offset is copied. The TLB is load-bearing, not a nice-to-have.

Address translation, worked

Given:  32-bit virtual addresses, 4 KB pages, single-level table
        4 KB = 2¹² → offset is the low 12 bits
                   → page number is the high 20 bits
                   → 2²⁰ = 1,048,576 entries per process

Virtual address 0x00004A3F
  = 0000 0000 0000 0000 0100 1010 0011 1111
    └──────── page 0x00004 ───────┘└─ offset 0xA3F ─┘
  page number = 0x4 = 4
  offset      = 0xA3F = 2623

  Page table[4] = frame 0x11 (17), present, rw
  Physical address = (17 × 4096) + 2623 = 69,632 + 2,623 = 72,255
                   = 0x00011A3F        // note the offset is unchanged

Quick facts to derive rather than memorise:
  page size 2ⁿ           → offset = n bits
  virtual address v bits → page number = v − n bits
  entries per table      = 2^(v−n)
  a 4-byte entry, 20-bit page number → 4 MB of page table PER PROCESS
  100 processes → 400 MB of page tables for mostly-empty address spaces.
  That waste is exactly what multi-level tables fix.

Multi-level page tables

Rather than one giant flat array, use a tree. Only the branches you actually use get allocated — and a typical process uses a tiny, sparse fraction of its address space, so nearly all branches are absent.

x86-64, 4-level, 4 KB pages: 48 bits of virtual address in use
  9 bits  → PML4 index      (512 entries)
  9 bits  → PDPT index
  9 bits  → PD index
  9 bits  → PT index
 12 bits  → offset
 = 48 bits → 256 TB of address space

A process touching 8 MB needs a handful of tables, not 512 GB of them.
Cost: 4 memory accesses per translation on a TLB miss — hence the TLB,
and hence huge pages.

Huge pages (2 MB or 1 GB): fewer entries cover far more memory.
  TLB REACH = entries × page size
    1536 entries × 4 KB  = 6 MB   ← a 1 GB working set thrashes the TLB
    1536 entries × 2 MB  = 3 GB   ← now it fits
  Used by databases and JVMs deliberately. Cost: more internal
  fragmentation, and allocation can fail once memory is fragmented.

Page table entry contents

The flags are asked about because each one enables a feature:

BitMeaningEnables
Present / validIs this page in physical memory?Demand paging — a clear bit triggers a page fault
DirtyHas it been written since being loaded?Clean pages can be evicted without a disk write — a large saving
Accessed / referencedTouched recently?Clock/LRU approximation for replacement
R/W, User/Supervisor, NXPermissionsRead-only code, copy-on-write, non-executable stack (defeats a whole class of exploits)
Frame numberWhere it actually isThe translation itself
Say this

"Paging splits the virtual space into fixed-size pages and physical memory into frames, and a per-process page table maps between them. Fixed size kills external fragmentation and the indirection means a process doesn't need contiguous physical memory. A flat table for a 32-bit space would be 4 MB per process, so real systems use multi-level tables and only allocate the branches in use — x86-64 uses four levels. That costs four memory accesses per translation, which is why the TLB exists: it caches translations and hits over 99% of the time, and without it paging would make every memory access several times slower."

15

Segmentation

segmentslogical divisionvs paging
Why it existed

Segmentation divides memory the way the programmer thinks: a code segment, a stack segment, a heap segment, one per array. Each has a base and a limit, so bounds checking and per-segment permissions are natural, and growing the stack means growing one segment. Paging divides memory the way the hardware finds convenient — fixed blocks with no meaning at all.

PagingSegmentation
Block sizeFixed (4 KB)Variable — as big as the logical unit needs
Divided byHardware convenienceLogical meaning
AddressOne number, split by the MMUExplicitly two parts: <segment, offset>
FragmentationInternal (bounded, ≤1 page)External (unbounded)
Programmer visibleNoYes
Sharing & protectionPer page — awkward unit for "this function"Per segment — natural: share one code segment between processes
GrowthAdd a page anywhereNeeds contiguous room after the segment, or a costly move
Why paging won

External fragmentation. Variable-size segments leave holes that eventually cannot be filled, and the only cures are compaction (copying live memory around, which is slow) or refusing allocations while memory is technically free. Paging's fixed blocks make that impossible by construction: any free frame satisfies any page. The price is internal fragmentation, which is bounded and small — a trade the industry made permanently.

The historical answer is segmentation with paging: divide logically into segments, then page each segment so it needn't be contiguous. x86 supported exactly this. In x86-64 long mode, segmentation is largely vestigial — the base is forced to zero for the main segments — and Linux uses a flat paged model. Segments survive as FS/GS, which the OS uses for thread-local storage and per-CPU data.

Say this

"Segmentation divides memory into variable-size logical units with a base and limit each, which makes protection and sharing natural — you can mark one code segment read-only and share it between processes. It lost to paging because variable sizes cause external fragmentation, and the only fixes are compaction or refusing allocations. Paging's fixed frames mean any free frame fits any page, so external fragmentation cannot occur; you pay bounded internal fragmentation instead. Modern x86-64 keeps segments only for thread-local storage."

16

Virtual memory & page replacement

demand pagingpage faultLRUClockBelady
Why it exists

Programs are bigger than RAM, and they don't need all of themselves at once — the 90% of your binary handling error paths and rare options is never touched. Virtual memory loads pages on demand, so a process's address space can far exceed physical memory, more processes fit at once, and startup is fast because you don't load what you don't run.

Handling a page fault

  1. The MMU finds the present bit clear and raises a page fault — a trap into the kernel. The faulting instruction is suspended, not aborted.
  2. The kernel checks whether the address is legal for this process. If not: SIGSEGV, process dies. This is what a segfault is.
  3. It finds a free frame. If none is free, it evicts a victim — writing it to disk first if it's dirty, which is why the dirty bit matters.
  4. It reads the page in from disk (or the swap file, or zeroes it for a fresh anonymous page).
  5. It updates the page table and the TLB, then re-executes the faulting instruction, which now succeeds.
Minor fault — the page is already in RAM (in the page cache, or shared with another process, or just needs a copy-on-write copy). ~microseconds. Very common and harmless.

Major fault — must read from disk. 1–10 ms on SSD-to-spinning-disk, i.e. ~10,000× a memory access. This is the number that makes virtual memory dangerous.

Effective access time = (1 − p) × memory + p × fault_time
With 100 ns memory and 8 ms faults, a fault rate p = 0.001 gives
0.999 × 100 ns + 0.001 × 8,000,000 ns ≈ 8.1 µs — 81× slower.
For under 10% slowdown you need p < ~0.0000012. Page faults must be vanishingly rare, not merely uncommon.

Replacement algorithms

AlgorithmEvictsVerdict
Optimal (OPT/MIN)The page used furthest in the futureProvably best, unimplementable — needs the future. Used as the benchmark others are measured against.
FIFOThe oldest loaded pageTrivial; ignores usage, so it happily evicts a hot page. Suffers Belady's anomaly.
LRULeast recently usedGood approximation of OPT under locality. Expensive to do exactly — needs a timestamp or list update on every access.
Clock / Second chanceThe first page whose reference bit is 0, sweeping a circular listThe practical answer. Approximates LRU using one hardware bit and no per-access bookkeeping.
LFULeast frequently usedAn old-but-once-popular page never leaves. Needs ageing/decay.
RandomAny pageSurprisingly not terrible, and immune to worst-case patterns

Traced side by side

Reference string:  7 0 1 2 0 3 0 4 2 3 0 3 2   ·   3 frames

FIFO
 7 | 7    0 | 7 0    1 | 7 0 1    2 | 2 0 1  ← evict 7 (oldest)
 0 | hit        3 | 2 3 1   ← evict 0     0 | 2 3 0  ← evict 1
 4 | 4 3 0      2 | 4 2 0      3 | 4 2 3      0 | 0 2 3
 3 | 0 3 ... → 15 faults

LRU
 same start, but at the 4:  frames hold 2 0 3, least recently USED is 2
 → evict 2, not 0 → 12 faults

OPT
 at each eviction, look forward and drop the page needed latest
 → 9 faults   ← the floor. LRU's 12 is respectable; FIFO's 15 is not.
Belady's anomaly

More frames can cause more page faults under FIFO. The classic string 1 2 3 4 1 2 5 1 2 3 4 5 gives 9 faults with 3 frames and 10 with 4.

Why: FIFO's eviction order has no relationship to future use, so adding a frame can change which pages happen to be resident in an unluckier way. Algorithms in the stack class — where the set of pages held with N frames is always a subset of the set held with N+1 — cannot suffer it. LRU and OPT are stack algorithms; FIFO and Clock are not. This is a favourite exam question precisely because it's counter-intuitive.

Frame allocation and the second policy

Replacement is only half the decision; how many frames each process gets is the other half.

  • Local replacement: a faulting process evicts only its own pages. Predictable per-process performance, but a process can thrash inside its own allocation while frames sit idle elsewhere.
  • Global replacement: evict from anyone. Better overall utilisation, but one greedy process degrades everyone and performance becomes non-reproducible. Most real systems are global with limits.
  • Proportional allocation gives frames by process size rather than equally, which stops a tiny process from hoarding.
17

Thrashing & memory pressure

working setswapOOM killercopy-on-write
Why it exists as a topic

Thrashing is the one failure mode where the OS's coping mechanism becomes the problem. The system spends more time moving pages than executing instructions, CPU utilisation collapses, and — the vicious part — a naive scheduler sees idle CPU and admits more processes, making it worse.

The working set model

W(t, Δ) = the set of pages referenced in the last Δ references — the process's current locality.

If a process has enough frames to hold its working set, it faults only when locality shifts. If it has fewer, it faults constantly, because it repeatedly evicts a page it is about to need.

The stability condition: Σ (working set sizes) ≤ available frames.
Violate it and the system thrashes. This is the entire theory in one line.

The practical control is page fault frequency: measure each process's fault rate, give more frames to processes above the upper bound, take frames from processes below the lower bound, and if no process can be satisfied, suspend one entirely to let the rest run. Counter-intuitive but correct — running fewer processes well beats running all of them badly.

Swap

TermMeans
PagingMoving individual pages between RAM and disk. Normal, continuous, healthy.
SwappingStrictly, moving an entire process out. Colloquially used for paging to the swap area.
ThrashingPage fault rate so high that useful work approaches zero.
swappinessLinux dial (0–100, default 60) trading anonymous-page eviction against page-cache eviction. Databases often set it low to keep their own memory resident.

Some swap is healthy: it lets the kernel evict genuinely cold anonymous pages and use the RAM for page cache instead. Zero swap doesn't prevent memory pressure; it just removes an option and makes the OOM killer fire sooner.

The OOM killer, and why it kills the wrong thing

When Linux cannot reclaim enough memory, it picks a victim by oom_score — dominated by memory footprint — and SIGKILLs it. The largest process is usually your main service. Symptoms: the process vanishes with no application log and no stack trace; the evidence is only in dmesg/journal as Out of memory: Killed process…. Being able to say "check dmesg, it was probably OOM-killed" is a recognisable production instinct.

Worse: Linux overcommits by default — malloc succeeds against memory that doesn't exist, because most programs don't touch everything they allocate. So the failure arrives not at allocation time as a clean NULL, but later, at an arbitrary page touch, as a kill. Tune with oom_score_adj, cgroup memory limits, or vm.overcommit_memory.

Copy-on-write

fork() must give the child a copy of a possibly-gigabyte address space.
Copying it eagerly would be absurd — and usually wasted, because the
child typically calls exec() immediately and discards all of it.

Instead: map the SAME frames into both, marked read-only.
  · Reads are free and shared.
  · The first WRITE traps → kernel copies just that one page, marks
    both writable, resumes.
  → cost is proportional to pages actually MODIFIED, not pages owned.

Same trick appears everywhere once you recognise it: mmap of a file,
shared libraries (one physical copy of libc for the whole system),
container image layers, database snapshots, and Redis's BGSAVE — which
forks and lets COW give it a consistent point-in-time view.

The gotcha: a write-heavy child after fork can double memory usage as
COW faults copy page after page. Redis is famous for this — a background
save during heavy writes can spike RSS toward 2×.

Diagnosing memory in production

$ free -h
              total  used  free  shared  buff/cache  available
Mem:            31G   28G   0.4G   0.1G        2.6G       1.1G
Swap:          2.0G  1.9G   0.1G
// swap almost full + available tiny = pressure. Not proof of thrash.

$ vmstat 1
 r  b   swpd   free  si   so    cs
 2  9  1.9G   400M  4200 3800  42000
// si/so = pages swapped IN/OUT per second. Sustained non-zero
// si+so is THE signature of thrashing. b=9 blocked, cs very high.

$ ps -eo pid,comm,rss,maj_flt --sort=-rss | head -3
  PID COMMAND          RSS  MAJFL
 4211 java        24500000  182004   // major faults = disk hits

$ dmesg -T | grep -i "killed process"
// the answer to "why did my service disappear at 3am"
RSS vs VSZ — the reading everyone gets wrong

VSZ is virtual size: everything mapped, including memory never touched, files mapped, and shared libraries. It can be enormous and means almost nothing — a JVM with a 40 GB VSZ is not using 40 GB. RSS is resident set size: physical pages actually present, and it's the number that matters for pressure. But RSS double-counts shared pages across processes, so summing RSS over a process tree overstates real usage. For an honest per-process figure use PSS (proportional set size, in /proc/PID/smaps_rollup), which divides shared pages by the number of sharers.

Say this

"Thrashing is when the sum of the processes' working sets exceeds physical memory, so every process keeps evicting a page it's about to need. The characteristic signature is CPU utilisation dropping while disk I/O saturates — and the trap is that a naive scheduler sees idle CPU and admits more work, which deepens it. The fix is to reduce the degree of multiprogramming: suspend a process entirely so the rest have enough frames. On Linux I'd confirm it with sustained non-zero si/so in vmstat plus rising major faults — and if the process vanished instead, check dmesg for the OOM killer."