In the previous lesson we arrived at the idea of paging and left it hanging: if all the pieces are the same size, fitting them together stops being a problem. Now it is time to deliver on that promise. This is the densest lesson in the module and probably the most profitable one in the whole course, because virtual memory is the mechanism that explains an enormous number of things you will see in production: why a process reserves 400 MB and only uses 31, why the first request to meteo-api after a deployment is slow and the following ones are not, why a server that starts paging stops responding all at once, and why the OOM killer picks what it picks.

You are going to see address translation with concrete numbers, calculate the impossible size of a flat 64-bit table, measure how much performance depends on the TLB, follow a page fault step by step, work out the replacement algorithms over one and the same reference string, and map the file 2026-08-31.dat into memory with mmap(). By the end you will be able to read free -h, vmstat and the OOM killer's trail with proper judgement.

Contents

  1. Pages and frames
  2. Address translation, with a complete numerical example
  3. The page table and its control bits
  4. Why a flat table is impossible in 64 bits
  5. Multilevel tables
  6. The TLB and the effective access time
  7. Huge pages
  8. Demand paging and the page fault
  9. Page replacement algorithms
  10. Frame allocation, thrashing and the working set
  11. Mapped files and shared memory with mmap()
  12. Copy-on-write revisited
  13. Swap, swappiness and the OOM killer
  14. Measuring: VSZ versus RSS, /proc/meminfo, free -h, vmstat

Pages and frames

These two terms are the foundation of the whole vocabulary and it is worth pinning them down with no ambiguity:

Page Frame
Where it lives The process's logical space Physical memory
Size 4 KB (typical) 4 KB (the same, necessarily)
How many there are Up to 2^36 on x86-64 RAM / 4 KB
Numbered from 0, per process 0, globally

Check your system's page size:

$ getconf PAGESIZE
4096

And meteo-01's numbers:

Logical space per process: 2^47 bytes = 128 TB → 2^35 pages
Installed RAM: 8 GB = 8,589,934,592 bytes → 2,097,152 frames

There are thirty-four billion times more possible pages than available frames. That disproportion is exactly what makes virtual memory possible: the vast majority of pages will never exist, and the ones that do exist do not all have to be in RAM at the same time.

Address translation, with a complete numerical example

A logical address is split into two fields:

┌───────────────────┬──────────────┐
│  Page number (p)  │  Offset (d)  │
└───────────────────┴──────────────┘
  • The offset takes as many bits as are needed to address inside a page. With 4 KB pages = 2^12 bytes, that is 12 bits.
  • The page number takes the rest.

The translation has three steps:

  1. Extract p and d from the logical address.
  2. Look up entry p in the page table, which holds the frame number f.
  3. The physical address is f × page_size + d.

The offset is never translated. A page and a frame are the same size, so the position inside the page is identical to the position inside the frame. Only which block changes, not where inside the block.

A complete example

Let us work with a small logical space so that everything fits on screen: 16-bit addresses and 4 KB pages.

Logical address: 16 bits
Page: 4 KB = 2^12 → 12-bit offset
Page number: 16 − 12 = 4 bits → 16 pages (0 to 15)

The ingestor's page table:

Page Frame Present
0 5 Yes
1 9 Yes
2 2 Yes
3 No
4 7 Yes

Translate the logical address 0x2A5C.

Step 1, decompose it:

0x2A5C in binary: 0010 1010 0101 1100
                  └──┘ └────────────┘
                   p         d

p = 0010₂ = 2
d = 1010 0101 1100₂ = 0xA5C = 2652

The arithmetic check, which is usually faster:

p = 0x2A5C / 4096 = 10844 / 4096 = 2 (integer division)
d = 0x2A5C % 4096 = 10844 − 2×4096 = 2652

Step 2, consult the table: page 2 → frame 2, present.

Step 3, assemble the physical address:

physical = 2 × 4096 + 2652 = 8192 + 2652 = 10844 = 0x2A5C

It matched the logical address by pure coincidence (page 2 happens to be in frame 2). Let us try another one.

Translate 0x105C:

p = 0x105C / 4096 = 4188 / 4096 = 1
d = 4188 − 4096 = 92

Table: page 1 → frame 9
physical = 9 × 4096 + 92 = 36864 + 92 = 36956 = 0x905C

Notice a revealing detail: 0x105C0x905C. The three right-hand hexadecimal digits do not change (05C), because they are the offset. Only the left-hand digit changes: 1 → 9, from page to frame. In hexadecimal the translation is visually obvious whenever the page size is a power of 16.

Translate 0x3200:

p = 3, d = 512
Table: page 3 → NOT PRESENT
→ PAGE FAULT

Here no translation is possible. The MMU raises an exception and the kernel takes over. What it does then is section 8.

The page table and its control bits

Each page table entry (PTE) takes 8 bytes on x86-64 and holds far more than a frame number:

Bit Name What it means Who sets it
0 P (Present) The page is in RAM The kernel
1 R/W 0 = read-only, 1 = read/write The kernel
2 U/S 0 = kernel mode only, 1 = accessible in user mode The kernel
3 PWT Write-through cache policy The kernel
4 PCD Cache disabled (for memory-mapped I/O) The kernel
5 A (Accessed) The page has been accessed The hardware
6 D (Dirty) The page has been written to The hardware
7 PS (Page Size) It is a huge page (2 MB or 1 GB) The kernel
8 G (Global) It is not invalidated on a process switch The kernel
12-51 Frame number The 40 bits of the physical frame The kernel
63 NX (No eXecute) The page cannot be executed The kernel

The ones that really matter, and why:

Bit P (present). It is the master switch of all virtual memory. If it is 0, any access causes a page fault. The kernel uses it for three different situations, and tells them apart with the remaining bits of the entry, which are free when P=0: page never loaded, page evicted to swap, or simply invalid address.

Bits R/W and NX. This is where the per-region protection we saw in /proc/<pid>/maps is implemented. The r-xp code region has R/W=0 (not writable); the rw-p stack and heap regions have NX=1 (not executable). That pair of bits is the W^X policy.

Bits A and D. They are special because the hardware writes them, not the operating system. Every time the CPU accesses a page it sets A=1; every time it writes it sets D=1. The kernel reads them and clears them periodically. Without them it would be impossible to implement the replacement algorithms: the kernel has no way of observing every memory access, so it needs the hardware to leave it that clue.

Bit D (dirty). It determines the cost of evicting the page. If D=0, the page has not changed since it was loaded, so it can be discarded outright (if it is needed again it is re-read from the file). If D=1, it has to be written to swap before the frame can be reused. It is exactly the distinction of pmap's Dirty column that we saw in 02-03, and the cost difference is zero versus milliseconds.

Why a flat table is impossible in 64 bits

Let us do the calculation that justifies all the complexity that follows.

On x86-64, 48 bits of virtual address are currently in use (the top 16 are a sign extension). With 4 KB pages:

Bits for the offset: 12
Bits for the page number: 48 − 12 = 36
Number of possible pages: 2^36 = 68,719,476,736

Size of a flat table:
68,719,476,736 entries × 8 bytes = 549,755,813,888 bytes = 512 GB

512 GB of page table. Per process. On an 8 GB machine with 180 processes, you would need 92 TB just for the tables.

It is absurd, and it is absurd for a very concrete reason: a flat table reserves an entry for every possible page, including the ones that will never exist. Remember the ingestor's memory map: the code sits at 0x400000 and the stack at 0x7ffd8b3a1000. Between them lies a chasm of 140 TB of addresses that will never be used, and a flat table would need an entry for every one of them.

The real process uses about 4,500 pages out of the 68 billion possible ones: 0.0000065 %. The data structure has to exploit that extreme sparsity.

Multilevel tables

The solution is to make the table hierarchical and sparse: split the page number into several fields, each one indexing a level, and create only the lower-level tables that are actually needed.

x86-64 uses four levels (five on the most recent CPUs). The 36-bit page number is split into four 9-bit fields:

┌────────┬────────┬────────┬────────┬──────────────┐
│ PML4   │  PDPT  │   PD   │   PT   │ Offset 12    │
│ 9 bits │ 9 bits │ 9 bits │ 9 bits │              │
└────────┴────────┴────────┴────────┴──────────────┘

Each level has 2^9 = 512 entries of 8 bytes = exactly 4,096 bytes, one page. That fit is no accident: each table takes up precisely one page, which makes managing them enormously simpler.

flowchart LR
    CR3["CR3 register<br/>(per process)"] --> PML4
    PML4["PML4<br/>512 entries"] -->|9-bit index| PDPT
    PDPT["PDPT<br/>512 entries"] -->|9-bit index| PD
    PD["Directory<br/>512 entries"] -->|9-bit index| PT
    PT["Page table<br/>512 entries"] -->|9-bit index| FRAME["Physical frame<br/>+ offset"]

The saving is spectacular. For a process with code at the bottom and a stack at the top:

1 PML4 table:                          4 KB
2 PDPT tables (one per area):          8 KB
2 PD tables:                           8 KB
~10 PT tables (for ~5,000 pages):     40 KB
                                     ───────
Total:                                 60 KB

60 KB against 512 GB. A factor of eight million.

The price: one translation requires four memory accesses (one per level) plus the access to the data itself. Five accesses where there used to be one. At ~100 ns per RAM access, that would be 500 ns for every memory read: the system would be 5 times slower than with no paging at all. Unacceptable.

That is the exact reason the TLB exists.

The TLB and the effective access time

The TLB (Translation Lookaside Buffer) is an associative cache, inside the MMU, that stores the page→frame translations used recently. It is small (between 64 and 1,536 entries on modern CPUs) and blazingly fast (under 1 ns).

The flow:

  1. The CPU generates a logical address.
  2. The MMU looks up the page number in the TLB.
  3. TLB hit: the frame is obtained directly. Cost ≈ 0.
  4. TLB miss: the four levels of tables in memory have to be walked, and then the result is inserted into the TLB.

Calculating the effective access time (EAT) is the formula you need to be able to work out:

EAT = h × (t_TLB + t_mem) + (1 − h) × (t_TLB + n × t_mem + t_mem)

where h is the hit rate, t_TLB the TLB lookup time, t_mem the memory access and n the number of levels.

With realistic values (t_TLB = 1 ns, t_mem = 100 ns, n = 4):

Hit rate Calculation EAT Degradation
100 % 1 + 100 101 ns 1.00×
99 % 0.99×101 + 0.01×501 105 ns 1.04×
95 % 0.95×101 + 0.05×501 121 ns 1.20×
90 % 0.90×101 + 0.10×501 141 ns 1.40×
70 % 0.70×101 + 0.30×501 221 ns 2.19×
50 % 0.50×101 + 0.50×501 301 ns 2.98×

The 99 % case in detail:

Hit  (99 %):  1 ns (TLB) + 100 ns (data)               = 101 ns
Miss (1 %):   1 ns + 4×100 ns (tables) + 100 ns (data) = 501 ns
EAT = 0.99 × 101 + 0.01 × 501 = 99.99 + 5.01 = 105.0 ns

The practical conclusion is blunt: at a 99 % hit rate you lose only 4 %; at 70 % you lose more than half your performance. The TLB is not a minor optimization, it is what makes paging viable at all.

And that is why the cost of a context switch we calculated in 02-01 matters so much: when cr3 changes, the previous process's TLB entries stop being valid and are invalidated. The incoming process starts with an empty TLB and a burst of misses. PCIDs (process context identifiers) mitigate this by tagging each entry with the process it belongs to, so the whole TLB does not have to be flushed.

You can see the real TLB misses:

$ sudo perf stat -e dTLB-load-misses,dTLB-loads -p 1877 sleep 10

 Performance counter stats for process id '1877':

        12,847,331      dTLB-load-misses    #    0.84% of all dTLB cache accesses
     1,529,204,882      dTLB-loads

      10.003 seconds time elapsed

A 0.84 % miss rate, that is to say 99.16 % hits. Perfectly healthy. If you saw a 15 % miss rate, you would have a process with a very scattered access pattern, and that is where huge pages come in.

Huge pages

The problem appears when a process works with enormous data sets. Take the aggregator processing a whole day of readings:

One day of data: 17 MB
4 KB pages needed: 17,000,000 / 4,096 ≈ 4,150 pages
TLB entries available (typical L2 TLB): 1,536

They do not fit. Walking those 17 MB causes continuous TLB misses, because the translations evict each other. And with 30 days of history it would be 124,000 pages: the TLB would be useless.

Huge pages solve this by using a larger page size: 2 MB or 1 GB on x86-64. It is achieved by stopping the hierarchy one level early (a page directory entry points directly at a 2 MB block instead of at a table).

4 KB 2 MB 1 GB
Pages for 17 MB 4,150 9 1
TLB entries used 4,150 9 1
Translation levels 4 3 2
Average internal fragmentation 2 KB 1 MB 512 MB
Eviction granularity to swap Fine Coarse Unworkable

The aggregator's 17 MB go from 4,150 TLB entries to 9. That fits with room to spare, and the TLB misses practically vanish.

On Linux there are two ways of using them:

$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

$ grep -i huge /proc/meminfo
AnonHugePages:    215040 kB
HugePages_Total:       0
HugePages_Free:        0
Hugepagesize:       2048 kB
  • THP (Transparent Huge Pages): the kernel automatically promotes large regions to 2 MB pages. The 215 MB of AnonHugePages show that it is already happening. It is convenient, but it has a known cost: the khugepaged daemon compacts memory in the background and can introduce pauses. That is why many databases recommend setting it to madvise or never.
  • Explicit huge pages: they are reserved at boot and the application asks for them expressly. More control, less convenience.

The rule of thumb: huge pages help when the working set is large and is traversed in a scattered way; they get in the way when memory is scarce and paging is needed, because evicting 2 MB in one go is very expensive.

Demand paging and the page fault

Here comes the piece that turns paging into virtual memory: not all of a process's pages need to be in RAM.

Demand paging means loading nothing until it is needed. When meteo-api starts, the kernel does not read its 820 KB of code: it creates the entries with P=0 and lets page faults bring in whatever is required. This explains the figures we saw in 02-03: libssl with 12 KB out of 1,024 KB loaded.

When the CPU accesses a page with P=0:

sequenceDiagram
    participant P as Process
    participant M as MMU
    participant K as Kernel
    participant D as Disk
    P->>M: access to address 0x3200
    M->>M: consults the table: bit P = 0
    M->>K: page fault exception (#PF)<br/>address in CR2
    K->>K: is it a valid address for this process?
    alt Invalid address
        K->>P: SIGSEGV → segmentation fault
    else Valid address
        K->>K: looks for a free frame
        alt No free frames
            K->>K: picks a victim (replacement algorithm)
            K->>D: if it is dirty, writes it to swap
        end
        K->>D: reads the page from the file or from swap
        D-->>K: data (0.1 - 10 ms)
        K->>K: updates the PTE: frame and P = 1
        K->>P: retries the instruction that faulted
    end

One essential detail at the end: the instruction that faulted is executed again from scratch. The process notices nothing at all. As far as it is concerned, that memory access simply took a long time.

The real cost of a page fault

This is where the numbers hurt. There are two very different kinds of fault:

Type What happens Cost Example
Minor fault The page is in RAM but not mapped into this process 1-3 µs Page cache, COW, library already loaded
Major fault It has to be read from disk 0.1-10 ms First read of a file, page in swap

The difference is three to four orders of magnitude:

Minor fault with an NVMe SSD:  ~2 µs
Major fault with an NVMe SSD:  ~100 µs   (50 times more)
Major fault with a hard disk:  ~8 ms     (4,000 times more)

And now the calculation that explains why the major fault rate has to be minuscule. With a normal memory access of 100 ns and a major fault of 8 ms:

EAT = (1 − p) × 100 ns + p × 8,000,000 ns
Fault rate p EAT Degradation
0 100 ns
1 in 1,000,000 108 ns 1.08×
1 in 100,000 180 ns 1.8×
1 in 10,000 900 ns
1 in 1,000 8,100 ns 81×

With one major fault per thousand accesses, the system runs 81 times slower. To keep the degradation below 10 % you need fewer than one fault per million accesses.

This is not an academic curiosity: it is exactly what happens to a server that starts paging. It does not degrade smoothly, it falls off a cliff. And it is the reason for the thrashing we will see in section 10.

You can see your processes' faults:

$ ps -eo pid,min_flt,maj_flt,comm -u meteora
    PID  MINFL  MAJFL COMMAND
   1842  84213      3 ingestor
   1877 291045    112 aggregator
   1901 138922      8 meteo-api

Minor faults are normal and plentiful: they are part of ordinary operation. The major ones are the ones that matter: 112 in the aggregator after 22 hours is perfectly healthy. If you saw 400,000, the process would be reading constantly from swap and that would be the problem to solve.

Page replacement algorithms

When a page fault occurs and there are no free frames left, somebody has to be evicted. The choice determines how many faults there will be afterwards.

We will work out all the algorithms over the same reference string, which is the only honest way of comparing them:

Reference string: 7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1
Frames available: 3

Optimal (OPT)

It evicts the page that will not be used again for the longest time. It is impossible to implement (it requires knowing the future), but it serves as a lower bound to compare against.

Ref Frames Fault Victim and why
7 [7,-,-]
0 [7,0,-]
1 [7,0,1]
2 [2,0,1] 7 (does not come back until the end)
0 [2,0,1] already there
3 [2,0,3] 1 (comes back at position 14)
0 [2,0,3] already there
4 [2,4,3] 0 (comes back later than 2 and 3)
2 [2,4,3]
3 [2,4,3]
0 [2,0,3] 4 (never comes back)
3 [2,0,3]
2 [2,0,3]
1 [2,0,1] 3 (does not come back)
2 [2,0,1]
0 [2,0,1]
1 [2,0,1]
7 [7,0,1] 2 (does not come back)
0 [7,0,1]
1 [7,0,1]

9 faults.

FIFO

It evicts the page that has been loaded the longest, regardless of how much it is used.

Ref Frames (arrival order) Fault
7 [7]
0 [7,0]
1 [7,0,1]
2 [0,1,2] ● 7 leaves
0 [0,1,2]
3 [1,2,3] ● 0 leaves
0 [2,3,0] ● 1 leaves
4 [3,0,4] ● 2 leaves
2 [0,4,2] ● 3 leaves
3 [4,2,3] ● 0 leaves
0 [2,3,0] ● 4 leaves
3 [2,3,0]
2 [2,3,0]
1 [3,0,1] ● 2 leaves
2 [0,1,2] ● 3 leaves
0 [0,1,2]
1 [0,1,2]
7 [1,2,7] ● 0 leaves
0 [2,7,0] ● 1 leaves
1 [7,0,1] ● 2 leaves

15 faults. 67 % more than the optimum.

Belady's anomaly

FIFO has a flaw that defies intuition: giving it more memory can increase the number of faults. With this string:

String: 1 2 3 4 1 2 5 1 2 3 4 5

With 3 frames:

Ref 1 2 3 4 1 2 5 1 2 3 4 5
Fault

9 faults.

With 4 frames:

Ref 1 2 3 4 1 2 5 1 2 3 4 5
Fault

10 faults. More memory, more faults.

The cause: FIFO does not have the stack property (that the set of pages held with n frames is contained in the set held with n+1 frames). When you add a frame, the eviction order changes completely and it can throw out precisely what was about to be needed. LRU and OPT do have that property and are therefore free of the anomaly.

It is the main reason why pure FIFO is not used in any real system: an algorithm you cannot promise will do better with more RAM is unacceptable.

LRU (least recently used)

It evicts the page that has gone longest without being used. It rests on the principle of temporal locality: what has been used recently will probably be used again.

Ref Frames (most recent on the right) Fault
7 [7]
0 [7,0]
1 [7,0,1]
2 [0,1,2] ● 7 leaves
0 [1,2,0]
3 [2,0,3] ● 1 leaves
0 [2,3,0]
4 [3,0,4] ● 2 leaves
2 [0,4,2] ● 3 leaves
3 [4,2,3] ● 0 leaves
0 [2,3,0] ● 4 leaves
3 [2,0,3]
2 [0,3,2]
1 [3,2,1] ● 0 leaves
2 [3,1,2]
0 [1,2,0] ● 3 leaves
1 [2,0,1]
7 [0,1,7] ● 2 leaves
0 [1,7,0]
1 [7,0,1]

12 faults. Between the optimum (9) and FIFO (15).

LRU's problem is the cost of implementing it. It requires keeping the pages ordered by last access, and that means updating a data structure on every memory access. You would need hardware that, on every read, moved an entry to the front of a list of thousands of elements. No CPU does this, because it would be ruinously expensive.

Approximations to LRU: second chance and the clock algorithm

Since exact LRU is unworkable, it is approximated using the A bit (accessed), which the hardware does maintain for free.

The clock algorithm arranges the frames in a circle with a pointer:

  1. The pointer points at a candidate frame.
  2. If its A bit is 0, it is evicted and the pointer advances.
  3. If its A bit is 1, it gets a second chance: A is set to 0 and the pointer moves on to the next one, evicting nothing.
  4. Repeat until a frame with A = 0 is found.
        ┌───────┐
   ┌───→│ P3 A=1│───┐
   │    └───────┘   ↓
┌───────┐        ┌───────┐
│ P0 A=0│        │ P4 A=1│
└───────┘        └───────┘
   ↑    ┌───────┐   │
   └────│ P2 A=0│←──┘
        └───────┘
             ↑ pointer

The intuition is exactly right: a page with A=1 has been used since the pointer's last lap, so it will probably keep being used. One with A=0 has not been touched in a whole lap: it is a good candidate.

A better variant uses two bits, A and D, which combine recent use and eviction cost:

A D Interpretation Eviction priority
0 0 Neither used nor modified 1st: the best victim, discarded for free
0 1 Not used but modified 2nd: it has to be written out, but nobody wants it
1 0 Used, not modified 3rd: discarded for free but in active use
1 1 Used and modified 4th: the worst victim

Linux uses a refined variant: two LRU lists (active and inactive) per memory zone, with promotion between them according to accesses, plus the A bit for ageing. It is approximate LRU, with the cost amortized down to almost nothing.

Final comparison

Algorithm Faults Against the optimum Implementable Belady's anomaly
Optimal 9 No No
LRU 12 +33 % Only approximately No
Clock (second chance) ~13 +44 % Yes, cheaply No
FIFO 15 +67 % Yes, trivially Yes

And here is the practical conclusion that is usually overlooked: the difference between the best possible algorithm and a decent one is 33 %; the difference between having enough RAM and not having it is a factor of 81. Optimizing the replacement algorithm matters far less than sizing memory correctly.

Frame allocation, thrashing and the working set

With 180 processes and 2 million frames, how many frames does each process get?

Equal allocation: frames / processes. Simple and unfair: meteo-api with 116 MB of data would get the same as an sshd using 9 MB.

Proportional allocation: hand out frames according to the size of each process.

frames_i = (size_i / Σ sizes) × total_frames

And an important distinction:

  • Local replacement: a process that faults can only steal frames from itself. Its performance is predictable but it does not take advantage of other processes' idle memory.
  • Global replacement: it can steal from anybody. Better overall use, but one process's performance depends on how the others behave. Linux uses global replacement.

Thrashing

Here is the most important phenomenon in this section. If a process does not have enough frames for its working set, it enters a destructive cycle:

flowchart TD
    A["Process with too few frames"] --> B["Page fault"]
    B --> C["Evicts a page<br/>it will need straight away"]
    C --> D["Blocks waiting for the disk"]
    D --> E["The CPU goes idle"]
    E --> F["The system thinks it can<br/>admit more processes"]
    F --> G["Fewer frames per process"]
    G --> B

The loop feeds itself: the less CPU is used, the more processes are admitted, and the less memory is left for each one.

The symptoms are unmistakable and worth memorizing:

Metric Value under thrashing Why
CPU usage Very low (5-15 %) Every process is waiting on the disk
%iowait Very high (60-90 %) There is nothing but disk activity
Major faults Thousands per second Every access faults
vmstat's si/so columns Hundreds of MB/s Constant swapping in both directions
Load average Sky-high Many processes in state D
The feeling The system "is not responding" It will not even accept an ssh

The combination CPU at 10 % with a load average of 40 is the diagnosis. If you saw this on meteo-01:

$ vmstat 1 3
procs -----------memory---------- ---swap-- -----io---- --system-- ------cpu-----
 r  b   swpd   free  buff  cache   si   so    bi    bo   in    cs  us sy id wa st
 1 24 4194300  22140  1024  81920 48932 51204 62104 51988 8421 21044  4  9  2 85  0
 0 27 4194300  19008  1024  79872 52108 49872 64220 50104 9102 23811  3 11  1 85  0

The data speaks for itself: 85 % %wa (I/O wait), 27 processes blocked in the b column, and si/so at around 50,000 KB/s in both directions. The system is fetching and shipping out the same pages over and over. No configuration tweak saves this: memory is missing.

The working set model

The conceptual solution was formulated by Peter Denning in 1968. The working set W(t, Δ) is the set of pages referenced by a process in the last Δ references.

Δ = 10,000 references

References:   ...2 6 1 5 7 7 7 5 1 6 2 3 4 1 2 3 4 4 4 3 4 4 4 1 3 2 3 4 4 4 4 3...
                └──────── Δ window ─────────┘
                W = {1, 2, 5, 6, 7}         W = {3, 4}

The rule is simple and powerful:

If Σ (working sets of all processes) > available frames
   → there is thrashing

And when that happens, the only correct solution is to suspend processes — to reduce the degree of multiprogramming — so that the ones left have their complete working set. It is counter-intuitive but true: running fewer processes makes the system get more done.

Linux does not implement Denning's model literally, but its memory pressure handling and the OOM killer pursue the same goal: when the system cannot sustain everyone, it kills somebody instead of letting nobody make progress.

Mapped files and shared memory with mmap()

mmap() is one of the most powerful system calls in UNIX: it makes a file appear as memory.

Instead of open + read + copying into a buffer, the file is mapped into the address space and accessed as if it were an array.

/* read_readings.c — compile with: gcc -Wall -o read_readings read_readings.c */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdint.h>

struct Reading {
    uint32_t station_id;
    uint32_t timestamp;
    float    temperature;
    float    humidity;
    float    pressure;
    uint32_t _reserved;       /* rounds the struct up to 24 bytes */
};

int main(void) {
    const char *path = "/var/lib/meteora/readings/2026-08-31.dat";

    int fd = open(path, O_RDONLY);
    if (fd == -1) { perror("open"); return 1; }

    struct stat st;
    if (fstat(fd, &st) == -1) { perror("fstat"); return 1; }

    size_t n = st.st_size / sizeof(struct Reading);
    printf("File of %ld bytes = %zu readings\n", (long)st.st_size, n);

    /* Map the whole file into memory */
    struct Reading *readings = mmap(NULL, st.st_size,
                                    PROT_READ, MAP_PRIVATE, fd, 0);
    if (readings == MAP_FAILED) { perror("mmap"); return 1; }

    close(fd);          /* the mapping outlives the descriptor being closed */

    /* Walk it as if it were an ordinary array */
    double sum = 0.0;
    float max_temp = -100.0f;
    for (size_t i = 0; i < n; i++) {
        sum += readings[i].temperature;
        if (readings[i].temperature > max_temp)
            max_temp = readings[i].temperature;
    }

    printf("Mean temperature: %.2f °C\n", sum / n);
    printf("Maximum temperature: %.2f °C\n", max_temp);

    munmap(readings, st.st_size);
    return 0;
}
$ ./read_readings
File of 17280000 bytes = 720000 readings
Mean temperature: 18.43 °C
Maximum temperature: 34.70 °C

What each part does and why it matters:

  • mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0) asks the kernel to associate the file with a region of the address space. NULL lets the kernel pick the address; PROT_READ makes it read-only; MAP_PRIVATE means that writes (were there any) would be copy-on-write and would never reach the file.
  • Nothing is read from disk at this point. The kernel merely creates page table entries with P=0. The 17 MB arrive on demand, as the loop touches them: every access to a new page causes a page fault that brings it in.
  • close(fd) does not invalidate the mapping. The mapping keeps its own reference to the file. It is a detail that surprises people, and it lets you close descriptors without losing access.
  • readings[i].temperature is ordinary pointer arithmetic. The compiler emits a memory access; the MMU and the kernel do the rest. There is not a single system call inside the loop.

That last point is the underlying reason for using mmap. Compare it with the read() alternative, applying what we calculated in 01-06:

read() in 4 KB blocks mmap()
System calls 17,280,000 / 4,096 = 4,219 1
Cost of syscalls at 1 µs 4,219 µs = 4.2 ms 1 µs
Copies of the data 2 (disk→cache→user buffer) 1 (disk→cache)
Extra memory The process's buffer None
If two processes read the same file Two copies in RAM They share the same pages
Random access lseek + read Direct indexing

The last row is especially valuable for Meteora: if the aggregator and meteo-api both map 2026-08-31.dat, the kernel's cache pages are physically shared. A single set of 17 MB in RAM serves both processes.

When mmap is not the right choice: for single-pass sequential reads of very large files, read() with a large buffer can be just as good or better, because mmap causes one page fault per 4 KB (thousands of exceptions), and because a file bigger than RAM mapped in its entirety can cause memory pressure.

mmap for shared memory

With MAP_SHARED instead of MAP_PRIVATE, writes do propagate: to the file and to every process that maps it.

int fd = open("/dev/shm/meteora-cache", O_RDWR | O_CREAT, 0640);
ftruncate(fd, 8 * 1024 * 1024);

void *cache = mmap(NULL, 8 * 1024 * 1024,
                   PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

This is exactly the rw-s- region we saw in meteo-api's pmap in 02-03: the four workers share a single 8 MB cache. The inter-process communication mechanisms and the synchronization this demands belong to Inter-Process Communication (IPC) and Synchronization and Mutual Exclusion.

Copy-on-write revisited

Now that you know the page table bits, the COW behind fork() that we saw in 02-01 can be explained precisely:

  1. fork() copies the parent's page tables into the child.
  2. In both copies, every writable page is marked R/W = 0 (read-only), and the kernel notes internally that they are COW.
  3. Both processes point at the same physical frames. Zero data copied.
  4. When either of them writes, the MMU detects R/W=0 and raises a protection page fault.
  5. The kernel tells it apart from a genuine error (it checks that the region is COW rather than truly read-only), copies that single 4 KB page, assigns it exclusively to the writer and sets R/W=1 on it.
  6. If the original frame's reference count drops to 1, the other process also gets R/W=1 back: there is nothing left to protect.

The numbers for meteo-api with its 148 MB:

Naive copy:  148 MB / 20 GB/s = 7.4 ms
COW:         ~37,000 page table entries ≈ 300 KB
             0.3 MB / 20 GB/s + bookkeeping ≈ 0.5 ms
Improvement factor: ~15×
If the child calls execve() immediately: ~0 pages are copied

And now you also understand why fork() can look cheap and then turn expensive: if the child writes all over the inherited memory, you will end up paying for the copy page by page, with one page fault per 4 KB. That is the classic complaint against fork() in huge processes, and the reason alternatives such as posix_spawn() and vfork() exist.

Swap, swappiness and the OOM killer

The swap area is the disk space where evicted anonymous pages are stored.

$ swapon --show
NAME      TYPE      SIZE USED PRIO
/dev/sda3 partition   4G 512M   -2

$ free -h
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi       3.1Gi       412Mi       528Mi       4.3Gi       3.9Gi
Swap:          4.0Gi       512Mi       3.5Gi

How to read free -h, which is where almost everybody goes wrong:

Column What it is The usual trap
total Installed RAM
used Used by processes
free Completely unused 412 MB does not mean memory is short
buff/cache Page cache and buffers Freed instantly if needed
available What a new process can obtain This is the column that matters

The 412 MB of free alarm a lot of people for no reason. The 4.3 GB of buff/cache are file pages the kernel keeps around in case they are needed again, and they are disposable on the spot. The memory that is really available is the 3.9 GB of available.

Free RAM is not RAM well spent. A system with free memory is wasting the chance to cache. What you want is a low free and a high available.

swappiness

It controls how aggressively the kernel evicts anonymous pages rather than discarding file cache:

$ cat /proc/sys/vm/swappiness
60
Value Behavior Suitable for
0 Swap only to avoid the OOM killer Databases with enough RAM
1-10 Very reluctant to swap Latency-sensitive servers
60 Default, balanced Desktop, general use
100 Treats anonymous and file pages alike Workloads with heavy file reading

For meteo-01 a value of 10 would be reasonable: it is preferable to discard page cache (recoverable with a fast sequential read) rather than send meteo-api's response cache to swap (which would cause major faults right on the critical path of the requests).

$ sudo sysctl -w vm.swappiness=10
$ echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-meteora.conf

An important warning: swappiness=0 does not disable swap, and disabling swap altogether is not a good idea either. With no swap, inactive anonymous pages — and on any system there are a lot of them — sit in RAM forever, and under memory pressure the kernel goes straight to the OOM killer with no intermediate options.

The OOM killer

When there is no memory left and no frames to evict, the kernel triggers the Out Of Memory killer: it picks a process and kills it.

The choice is based on a score:

$ cat /proc/1901/oom_score
187
$ cat /proc/1901/oom_score_adj
0

oom_score is proportional to the memory consumed, with adjustments: it penalizes large processes and those of ordinary users, and it protects root's processes and PID 1. oom_score_adj runs from −1000 (never kill it) to +1000 (kill it first) and is the part you can tune yourself:

# Protect the ingestor: losing readings is irreversible
$ echo -900 | sudo tee /proc/1842/oom_score_adj

# Sacrifice the backup first
$ echo 800 | sudo tee /proc/3901/oom_score_adj

The trail it leaves in the log:

$ sudo dmesg -T | grep -A4 'Out of memory'
[Sun Aug 31 04:12:33 2026] aggregator invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[Sun Aug 31 04:12:33 2026] Mem-Info:
[Sun Aug 31 04:12:33 2026] active_anon:1842103 inactive_anon:204118 isolated_anon:0
[Sun Aug 31 04:12:33 2026] Tasks state (memory values in pages):
[Sun Aug 31 04:12:33 2026] [   pid ]   uid  tgid total_vm      rss  pgtables_bytes swapents oom_score_adj name
[Sun Aug 31 04:12:33 2026] [   1877]   998  1877  1204832  1180221     9539584        0             0 aggregator
[Sun Aug 31 04:12:33 2026] [   1901]   998  1901    37080    33785      274432        0             0 meteo-api
[Sun Aug 31 04:12:33 2026] Out of memory: Killed process 1877 (aggregator) total-vm:4819328kB, anon-rss:4720884kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:9316kB oom_score_adj:0

How to read it line by line, which is what you need to be able to do when you are on call:

  • aggregator invoked oom-killer: the one that triggered the OOM was the aggregator when it asked for memory. That does not necessarily make it the culprit, although here it is.
  • The task table lists every process with its rss in pages. The aggregator has 1,180,221 pages × 4 KB = 4.5 GB. meteo-api has 33,785 × 4 KB = 132 MB.
  • Killed process 1877 (aggregator) with anon-rss:4720884kB: the one consuming the most anonymous memory was chosen, which is the usual criterion.
  • anon-rss versus file-rss: those 4.7 GB are anonymous, that is, dynamic memory with no file backing it. Killing the process frees them immediately. If it had been file-rss, killing it would have freed almost nothing.

That figure of 4.5 GB in an aggregator that under normal conditions uses 31 MB is the complete diagnosis: it is processing a whole day by loading everything into memory instead of block by block. The fix is a design fix — mmap or block-wise reading — not a configuration one.

Remember too what we saw in 02-01: when the OOM killer kills a process, that process dies from SIGKILL (signal 9), which your supervisor would detect with WIFSIGNALED(status) and WTERMSIG(status) == 9.

Measuring: VSZ versus RSS, /proc/meminfo, free -h, vmstat

We close with the tools and how to read them without going wrong.

$ ps -eo pid,vsz,rss,comm -u meteora
    PID    VSZ   RSS COMMAND
   1842 408212 18204 ingestor
   1877 412308 31456 aggregator
   1901 486300 148320 meteo-api
Metric What it measures When you use it The trap
VSZ Reserved address space Almost never Includes what was never touched; reserving is free
RSS Pages actually in RAM A quick estimate Counts shared pages in every process
PSS RSS with shared pages divided up Adding up the consumption of several processes Only in smaps
USS Memory exclusive to the process How much is freed by killing it Only in smaps

The problem with RSS, with a concrete example: if meteo-api has 4 workers and each one reports 148 MB of RSS, adding them gives 592 MB, but the real consumption may be 200 MB because they share code, libc and the /dev/shm cache. PSS solves this by dividing each shared page among those using it:

$ sudo awk '/^Pss:/ {sum += $2} END {print sum " kB (PSS)"}' /proc/1901/smaps
94208 kB (PSS)

94 MB in reality against 148 MB of RSS. To account for memory on a server whose processes share a great deal, PSS is the correct metric.

/proc/meminfo gives the global picture:

$ grep -E 'MemTotal|MemAvailable|Cached|Dirty|Writeback|AnonPages|Mapped|Slab|SwapTotal|SwapFree' /proc/meminfo
MemTotal:        8122448 kB
MemAvailable:    4089216 kB
Cached:          4198400 kB
Dirty:             28160 kB
Writeback:             0 kB
AnonPages:       3021312 kB
Mapped:           412160 kB
Slab:             298432 kB
SwapTotal:       4194300 kB
SwapFree:        3670012 kB

The lines that really tell you something:

  • MemAvailable: 3.9 GB. The only one that answers "how much memory can I use?".
  • AnonPages 2.9 GB: the processes' anonymous memory. It can only go to swap.
  • Cached 4 GB: file pages. Disposable on the spot.
  • Dirty 28 MB: modified but not yet written to disk. If this figure climbs a lot, there is a write bottleneck. Forcing it to be flushed is what fsync() does.
  • Slab 298 MB: the kernel's own data structures (inodes, dentries, task_struct). It is not process memory.

And vmstat to see the dynamics:

$ vmstat 2 3
procs -----------memory---------- ---swap-- -----io---- --system-- ------cpu-----
 r  b   swpd   free  buff  cache   si   so    bi    bo   in    cs  us sy id wa st
 2  0 524288 421904  1024 4198400    0    0   142   288 4211  8877 12  4 83  1  0
 1  0 524288 419872  1024 4199424    0    0    88   412 4402  9104 14  5 80  1  0
 3  1 524288 418112  1024 4200448    0   16   204  1128 5108 10944 16  6 76  2  0

The critical columns for memory:

  • si/so (swap in / swap out, in KB/s): the most important metric. A swpd of 512 MB with si/so at zero is harmless: they are old pages evicted long ago that nobody is asking for. What is serious is sustained si/so: that really is thrashing.
  • b: processes blocked in uninterruptible I/O (the D state from 02-01).
  • wa: percentage of CPU waiting on I/O.

The diagnostic rule that sums up the lesson: look at si/so, not at swpd. Having swap occupied is normal; having swap in constant motion means RAM is missing.

Common Mistakes and Tips

Panicking because free is low. The right column is available. A healthy system has little free memory and a lot of cache, because idle RAM is wasted RAM.

Confusing swpd with actually paging. The fact that there are 512 MB in swap says nothing on its own: they may have been sitting there for days with nobody asking for them. What signals a problem is sustained si/so in vmstat.

Disabling swap "so the system does not slow down". With no swap, the kernel loses its intermediate tool and under memory pressure it goes straight to the OOM killer. The sensible configuration is to have swap and lower swappiness, not to remove it.

Adding up the RSS of several processes. It gives an inflated total because shared pages are counted several times. To add them up, use PSS.

Believing that the OOM killer kills the culprit. It kills whoever has the highest score, which is usually the biggest one. If the aggregator causes the shortage and meteo-api is the one holding the most memory, meteo-api dies. Protect what is critical with a negative oom_score_adj.

Enabling THP everywhere. Transparent 2 MB pages help workloads with large working sets, but khugepaged compacting memory can introduce pauses of tens of milliseconds. Databases usually recommend madvise or never for exactly this reason.

Reading a SIGSEGV as a system bug. It is the MMU doing its job: the process accessed an address with no valid translation or without the right permissions. Compare the address against /proc/<pid>/maps to find out whether it was a corrupt pointer (it falls in a gap) or a write into a read-only area.

Diagnostic tip: when you suspect a memory problem, this is the order that works: free -h (look at available), vmstat 1 (look at si/so and wa), ps -eo pid,rss,maj_flt --sort=-rss | head (who is consuming and who is faulting), pmap -x <pid> (which region is growing) and dmesg -T | grep -i oom (whether somebody has already died). Five commands and you have the whole picture.

Exercises

Exercise 1: address translation and table sizes

A system has 32-bit logical addresses and 4 KB pages.

  1. How many bits do the page number and the offset take? How many pages are there at most?
  2. With this page table, translate the logical addresses 0x00003ABC, 0x00001234 and 0x00006000:
Page Frame Present
0 0x0A Yes
1 0x1F Yes
2 0x03 Yes
3 0x2C Yes
4 No
  1. Calculate the size of a flat table with 4-byte entries. Compare it with the 64-bit case from section 4 of the lesson.
  2. With a 96 % TLB hit rate, a memory access of 80 ns, a TLB lookup of 2 ns and 2 table levels, calculate the effective access time.

Exercise 2: comparing replacement algorithms

The aggregator generates this page reference string:

1 2 3 4 1 2 5 1 2 3 4 5
  1. Calculate the page faults with 3 frames for: optimal, FIFO and LRU. Show the state of the frames at each step.
  2. Repeat with 4 frames.
  3. Which algorithm exhibits Belady's anomaly? Demonstrate it with your own numbers.
  4. If every major fault costs 8 ms, calculate the total time lost in each case with 3 frames.

Exercise 3: diagnosing a server with memory problems

meteo-01 is responding extremely slowly. You collect this data:

$ free -h
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi       7.4Gi       102Mi        12Mi       298Mi       118Mi
Swap:          4.0Gi       3.8Gi        204Mi

$ vmstat 2 3
procs -----------memory---------- ---swap-- -----io---- --system-- ------cpu-----
 r  b   swpd   free  buff  cache   si   so    bi    bo   in    cs  us sy id wa st
 0 31 3985408 104448  512 305152 42104 39882 51204 40118 9821 24102  3  8  1 88  0
 1 29 3985408 102112  512 303104 44210 41004 53108 41220 10104 25811  2  9  1 88  0
 0 33 3985408 101888  512 301056 43108 40112 52004 40988 9902 24998  3  8  1 88  0

$ ps -eo pid,rss,maj_flt,comm --sort=-rss | head -5
    PID    RSS  MAJFL COMMAND
   1877 4720884 892104 aggregator
   1901 148320  41022 meteo-api
   1842  18204   8104 ingestor
  1. Diagnose what is wrong with the system. Name the phenomenon and justify it with at least four pieces of evidence.
  2. Why is CPU usage at 3 % if the system is crawling?
  3. Identify the root cause. How much memory should the aggregator be using, given that it processes one day of readings?
  4. Propose three solutions: an immediate one, a configuration one and a design one. State which is the correct one.
  5. Write the piece of code that would fix the problem at its root.

Solutions

Solution 1

1. Decomposing the address.

4 KB page = 2^12 bytes  →  offset = 12 bits
Page number = 32 − 12 = 20 bits
Maximum pages = 2^20 = 1,048,576

2. Translations.

0x00003ABC:

In binary:  0000 0000 0000 0000 0011 | 1010 1011 1100
                   p = 0x00003 = 3    |    d = 0xABC = 2748

Arithmetically: 0x3ABC = 15036;  15036 / 4096 = 3;  15036 % 4096 = 2748

Table: page 3 → frame 0x2C = 44, present
physical = 44 × 4096 + 2748 = 180,224 + 2,748 = 182,972 = 0x0002CABC

The hexadecimal shortcut: the three right-hand digits (ABC) are preserved and the left-hand ones go from 00003 to 0002C.

0x00001234:

p = 0x00001 = 1,  d = 0x234 = 564
Table: page 1 → frame 0x1F = 31
physical = 31 × 4096 + 564 = 126,976 + 564 = 127,540 = 0x0001F234

0x00006000:

p = 0x00006 = 6,  d = 0
Table: page 6 does not exist (there are only entries 0-4)
→ PAGE FAULT on an invalid address
→ The kernel checks /proc/pid/maps: it belongs to no region
→ SIGSEGV: segmentation fault

It is important to tell this case apart from page 4, which does exist but has Present = No: there the fault would be recoverable (the kernel would bring it in from swap or from the file and retry the instruction), whereas here it is a genuine program error.

3. Size of the flat table in 32 bits.

2^20 entries × 4 bytes = 4,194,304 bytes = 4 MB per process

Comparison with 64 bits:

32 bits 64 bits (48 in use)
Page bits 20 36
Entries 1,048,576 68,719,476,736
Bytes per entry 4 8
Flat table 4 MB 512 GB
With 180 processes 720 MB 92 TB

And here is the key observation of the exercise: 4 MB per process is already too much. With 180 processes that is 720 MB of tables alone, almost 9 % of meteo-01's 8 GB, and the vast majority of those entries would be empty. That is why even 32-bit systems used multilevel tables (32-bit x86 had two levels). The flat table was never viable; in 64 bits it goes from unviable to plainly absurd.

4. Effective access time.

Hit  (96 %):  2 ns (TLB) + 80 ns (data)                    = 82 ns
Miss (4 %):   2 ns + 2 × 80 ns (two levels) + 80 ns (data) = 242 ns

EAT = 0.96 × 82 + 0.04 × 242
    = 78.72 + 9.68
    = 88.4 ns

Degradation against a pure access (80 ns): 88.4 / 80 = 1.105, that is 10.5 %.

It is worth noticing how much weight that 4 % of misses carries: it contributes 9.68 ns out of the 88.4 total, 11 % of the time, while being only 4 % of the accesses. It is the typical arithmetic of caches, and it explains why improving from 96 % to 99 % hits is worth the effort:

EAT at 99 % = 0.99 × 82 + 0.01 × 242 = 81.18 + 2.42 = 83.6 ns  (+4.5 %)

Solution 2

1. With 3 frames.

Optimal (evicts the page that will reappear last):

Ref 1 2 3 4 1 2 5 1 2 3 4 5
F1 1 1 1 1 1 1 1 1 1 3 3 3
F2 2 2 2 2 2 2 2 2 2 4 4
F3 3 4 4 4 5 5 5 5 5 5
Fault

The eviction decisions: at position 4, 3 leaves (it reappears at 10, later than 1 and 2); at 7, 4 leaves (it reappears at 11); at 10, 1 leaves (it never comes back); at 11, 2 leaves (it does not come back either).

7 faults.

FIFO:

Ref 1 2 3 4 1 2 5 1 2 3 4 5
F1 1 1 1 4 4 4 5 5 5 5 5 5
F2 2 2 2 1 1 1 1 1 3 3 3
F3 3 3 3 2 2 2 2 2 4 4
Fault

9 faults.

LRU:

Ref 1 2 3 4 1 2 5 1 2 3 4 5
F1 1 1 1 4 4 4 5 5 5 3 3 3
F2 2 2 2 1 1 1 1 1 1 4 4
F3 3 3 3 2 2 2 2 2 2 5
Fault

10 faults.

2. With 4 frames.

Optimal:

Ref 1 2 3 4 1 2 5 1 2 3 4 5
F1 1 1 1 1 1 1 1 1 1 1 4 4
F2 2 2 2 2 2 2 2 2 2 2 2
F3 3 3 3 3 3 3 3 3 3 3
F4 4 4 4 5 5 5 5 5 5
Fault

With 4 frames, 1, 2, 3 and 4 all fit from the start. When 5 arrives, 4 is evicted, because it reappears at position 11, later than 1, 2 and 3. At position 10 the 3 is already resident, so there is no fault. At 11 the 4 has to be brought back and 1 is evicted, since it is never used again.

6 faults.

FIFO:

Ref 1 2 3 4 1 2 5 1 2 3 4 5
F1 1 1 1 1 1 1 5 5 5 5 4 4
F2 2 2 2 2 2 2 1 1 1 1 5
F3 3 3 3 3 3 3 2 2 2 2
F4 4 4 4 4 4 4 3 3 3
Fault

10 faults.

LRU:

Ref 1 2 3 4 1 2 5 1 2 3 4 5
F1 1 1 1 1 1 1 1 1 1 1 1 5
F2 2 2 2 2 2 2 2 2 2 2 2
F3 3 3 3 3 5 5 5 5 4 4
F4 4 4 4 4 4 4 3 3 3
Fault

A trace of the evictions: when 5 arrives (position 7), the order of use is 3, 4, 1, 2, so 3 leaves. At position 10 the 3 is needed and the least recently used is 4. At 11 the 4 is needed and 5 leaves. At 12 the 5 is needed and 1 leaves. Notice the pattern: the last three references all fault because LRU has just evicted exactly what is asked for next.

8 faults.

3. Belady's anomaly.

Algorithm 3 frames 4 frames Does more memory help?
Optimal 7 6 Yes (−1)
FIFO 9 10 NO: it gets worse (+1)
LRU 10 8 Yes (−2)

FIFO exhibits the anomaly: going from 3 to 4 frames raises the faults from 9 to 10.

The structural explanation: FIFO does not satisfy the stack property. Formally, an algorithm satisfies it if the set of resident pages with n frames is always a subset of the set with n+1 frames. LRU and OPT satisfy it because their decision depends on the reference pattern, which does not change when frames are added. FIFO decides by arrival order, and that order is completely altered by changing the number of frames: with 4 frames, pages 1 and 2 survive longer and end up being evicted just before they are needed again.

It is an important result because it breaks an intuition that seemed safe, and it is the practical reason why no real system uses pure FIFO: you cannot promise that adding RAM will improve performance.

4. Time lost with 3 frames.

Algorithm Faults Time lost Against the optimum
Optimal 7 7 × 8 ms = 56 ms
FIFO 9 9 × 8 ms = 72 ms +28.6 %
LRU 10 10 × 8 ms = 80 ms +42.9 %

An honest observation about these numbers: here LRU comes out worse than FIFO, which contradicts the general intuition. It is an artefact of this particular string, designed precisely to exhibit Belady's anomaly. With real strings, which show strong temporal locality, LRU clearly beats FIFO. It is a good reminder that a twelve-reference string proves nothing about an algorithm's general behavior; for that you need real traces of millions of references.

What the comparison does prove is the order of magnitude of the problem: 12 memory accesses that should have cost 1.2 microseconds have cost between 56 and 80 milliseconds. A factor of 50,000. When frames are missing, the replacement algorithm is the least of your worries.

Solution 3

1. Diagnosis: thrashing.

The evidence that confirms it, one item at a time:

Evidence Value What it means
available 118 Mi out of 7.8 Gi Memory practically exhausted
Swap used 3.8 Gi out of 4.0 Gi Swap is full as well
si/so ~42,000 / ~40,000 KB/s 40 MB/s in both directions at once
wa 88 % The CPU does nothing but wait for the disk
b 29-33 processes Almost the whole system in state D
The aggregator's MAJFL 892,104 Nearly a million major faults

The decisive figure is si and so both high at the same time. If there were only so, the system would be freeing memory in an orderly way. Traffic in and out simultaneously at 40 MB/s means that the same pages are being evicted and fetched back without pause: the working set does not fit in RAM and every evicted page is needed immediately afterwards. It is the exact definition of the feedback loop in the lesson's diagram.

2. Why the CPU is at 3 %.

Because there is nothing to run. Almost every process is in state D, blocked waiting for the disk to bring in a page. The r column (runnable) reads 0 or 1, while b (blocked) reads 31.

This is the most deceptive pattern of all: an almost idle CPU with the system completely stalled. Anyone looking only at CPU usage will conclude that the server is fine and go looking for the problem somewhere else. The combination to recognize instantly is low us+sy + sky-high wa + high b.

A calculation that puts a size on it: 892,104 major faults in the aggregator, at about 100 µs each with an SSD, come to 89 seconds of pure waiting. With a mechanical disk at 8 ms it would be ~2 hours of disk time.

3. Root cause.

The aggregator has 4,720,884 KB = 4.5 GB of RSS on a 7.8 GB machine. On its own it is 60 % of the total RAM. Neither meteo-api (148 MB) nor ingestor (18 MB) matters by comparison.

How much it should be using:

One day of readings: 17 MB (from the course glossary)
Aggregation structures:
  Stations × hours × 5 fields ≈ 50 × 24 × 5 × 8 bytes = 48 KB
Working buffers, libc, code:  ~15 MB

Reasonable consumption: 30-50 MB
Actual consumption:     4,500 MB
Excess factor:          ~100×

And at 24 bytes per reading, those 4.5 GB amount to some 196 million readings: more than 270 days of data. The diagnosis is immediate: the aggregator is loading the entire history into memory instead of the one day it needs to process, most likely because of a readdir over /var/lib/meteora/readings/ with no date filter, accumulating everything into an in-memory structure.

4. Three solutions.

Immediate (get the server back now):

$ sudo kill 1877

It frees 4.5 GB instantly. The system stops paging within seconds. It is a plaster, not a cure: it will happen again on the next run.

Configuration (contain the damage):

# Limit the service's memory with systemd
$ sudo systemctl edit meteora-aggregator
[Service]
MemoryMax=512M
MemoryHigh=384M

# Protect the critical processes from the OOM killer
$ echo -900 | sudo tee /proc/1842/oom_score_adj   # ingestor
$ echo -500 | sudo tee /proc/1901/oom_score_adj   # meteo-api

# Reduce the tendency to swap
$ sudo sysctl -w vm.swappiness=10

With MemoryMax=512M (a cgroup limit, the subject of 06-02), an aggregator that tries to go past 512 MB dies on its own without dragging the rest of the system down. This turns a system-wide outage into an isolated failure, which is exactly what you want. It does not fix the bug, but it contains it.

Design (the correct one):

Process as a stream, not by loading everything. Two valid variants: read in blocks with read(), or map the day's file with mmap() and let demand paging manage the memory.

5. The code that fixes it.

The mmap() version, which applies directly what the lesson covered:

/* aggregate_day.c — processes ONE day without loading it all into its own memory */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdint.h>

struct Reading {
    uint32_t station_id;
    uint32_t timestamp;
    float    temperature;
    float    humidity;
    float    pressure;
    uint32_t _reserved;
};

#define MAX_STATIONS 64
#define HOURS_DAY    24

struct Accumulator {
    double sum_temp;
    double sum_hum;
    double sum_pres;
    uint32_t n;
};

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s YYYY-MM-DD\n", argv[0]);
        return 1;
    }

    char path[256];
    snprintf(path, sizeof path,
             "/var/lib/meteora/readings/%s.dat", argv[1]);

    int fd = open(path, O_RDONLY);
    if (fd == -1) { perror("open"); return 1; }

    struct stat st;
    if (fstat(fd, &st) == -1) { perror("fstat"); close(fd); return 1; }

    struct Reading *readings = mmap(NULL, st.st_size,
                                    PROT_READ, MAP_PRIVATE, fd, 0);
    if (readings == MAP_FAILED) { perror("mmap"); close(fd); return 1; }
    close(fd);

    /* Hint to the kernel: we will walk it in order and will not re-read it */
    madvise(readings, st.st_size, MADV_SEQUENTIAL);

    /* Accumulators: 64 × 24 × 32 bytes = 48 KB, FIXED size */
    static struct Accumulator acc[MAX_STATIONS][HOURS_DAY];
    memset(acc, 0, sizeof acc);

    size_t n = st.st_size / sizeof(struct Reading);
    for (size_t i = 0; i < n; i++) {
        uint32_t station = readings[i].station_id % MAX_STATIONS;
        uint32_t hour    = (readings[i].timestamp / 3600) % HOURS_DAY;
        struct Accumulator *a = &acc[station][hour];
        a->sum_temp += readings[i].temperature;
        a->sum_hum  += readings[i].humidity;
        a->sum_pres += readings[i].pressure;
        a->n++;
    }

    munmap(readings, st.st_size);

    for (int s = 0; s < MAX_STATIONS; s++)
        for (int h = 0; h < HOURS_DAY; h++)
            if (acc[s][h].n > 0)
                printf("%s %02d:00 station=%d n=%u T=%.2f H=%.1f P=%.1f\n",
                       argv[1], h, s, acc[s][h].n,
                       acc[s][h].sum_temp / acc[s][h].n,
                       acc[s][h].sum_hum  / acc[s][h].n,
                       acc[s][h].sum_pres / acc[s][h].n);
    return 0;
}

Why this fixes the problem at its root:

  • One day per run. The path is built from the date it is given: loading the whole history by accident is impossible.
  • The accumulators have a fixed size: 64 × 24 × 32 bytes = 48 KB, independent of the volume of data. This is the heart of the fix: the process's memory no longer grows with the input.
  • mmap consumes no RSS of its own. The mapped pages belong to the kernel's page cache, which is disposable under memory pressure (they are clean and file-backed, as we saw in 02-03). If RAM runs short, the kernel discards them without writing anything and re-reads them later. Compared with the 4.5 GB of anonymous memory in the previous version — which could only go to swap — this changes the system's behavior under pressure completely.
  • madvise(MADV_SEQUENTIAL) tells the kernel that access will be sequential. The kernel turns on aggressive read-ahead and discards the pages already walked sooner. It is a small optimization to write and a very effective one on full traversals.

The resulting consumption:

Process RSS:     ~15 MB (code + libc + accumulators)
Page cache:      up to 17 MB, freeable instantly
Effective total: ~32 MB against 4,500 MB

A reduction by a factor of 140, and not a single anonymous page that could drag the system into thrashing.

Conclusion

Paging divides the logical space into pages and physical memory into frames of the same size, and translates every address by separating the page number from the offset — which is never translated. Each table entry carries the frame plus a set of control bits that govern everything: P enables virtual memory, R/W and NX implement W^X, and A and D, written by the hardware, are the only clue the kernel has for deciding who to evict and at what cost.

A flat table in 64 bits would take 512 GB per process, so tables are multilevel and sparse: four levels of 9 bits, each table occupying exactly one page, and 60 KB in practice instead of 512 GB. The price is four memory accesses per translation, and that is why the TLB exists: at a 99 % hit rate you lose 4 % of your performance, at 70 % you lose more than half. Huge pages cut 4,150 TLB entries down to 9 for the aggregator's 17 MB, at the cost of internal fragmentation and a coarse eviction granularity.

Demand paging means nothing is loaded until it is touched, which explains libc with only 44 % of its code in RAM. A minor fault costs microseconds and is normal; a major fault costs milliseconds, and with one per thousand accesses the system runs 81 times slower. The replacement algorithms — optimal as a bound, FIFO with its Belady's anomaly, LRU unworkable in its exact form, and the clock as a cheap approximation using the A bit — move within a margin of 33 %, far less than having enough RAM matters. When the working set does not fit, thrashing appears: CPU at 3 %, wa at 88 % and the system at a standstill.

And all of this can be touched with your own hands: mmap() turns 4,219 system calls into one and lets two processes physically share the same 2026-08-31.dat; the copy-on-write behind fork() is simply R/W=0 plus a protection fault; swappiness decides what gets sacrificed first; and the OOM killer leaves a trail in dmesg that you read process by process. The rule that sums up the operational side: look at available and not at free, look at si/so and not at swpd, and add up PSS and not RSS.

With this we close memory. What remains is the other great resource the operating system administers, and the one that has appeared in every major fault in this lesson: storage. How much that disk access we have been counting in milliseconds really costs, why an SSD changes all the rules, how requests are ordered to minimize head movement and what RAID configuration /var/lib/meteora deserves. That is what we will see in Storage Management.

Operating Systems Fundamentals

Module 1: Introduction to Operating Systems

Module 2: Resource Management

Module 3: Concurrency

Module 4: File Structures

Module 5: System Protection and Security

Module 6: Virtualization and Containers

Module 7: Administration and Troubleshooting in Practice

© Copyright 2026. All rights reserved